summaryrefslogtreecommitdiff
path: root/addons/web_editor/static/src
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web_editor/static/src')
-rw-r--r--addons/web_editor/static/src/js/backend/convert_inline.js (renamed from addons/web_editor/static/src/js/editor/transcoder.js)109
-rw-r--r--addons/web_editor/static/src/js/backend/field_html.js476
-rw-r--r--addons/web_editor/static/src/js/backend/fields.js532
-rw-r--r--addons/web_editor/static/src/js/common/ace.js (renamed from addons/web_editor/static/src/js/widgets/ace.js)3
-rw-r--r--addons/web_editor/static/src/js/content/body_manager.js60
-rw-r--r--addons/web_editor/static/src/js/editor/editor.js286
-rw-r--r--addons/web_editor/static/src/js/editor/rte.js749
-rw-r--r--addons/web_editor/static/src/js/editor/rte.summernote.js1274
-rw-r--r--addons/web_editor/static/src/js/editor/summernote.js2436
-rw-r--r--addons/web_editor/static/src/js/editor/translator.js375
-rw-r--r--addons/web_editor/static/src/js/iframe.js153
-rw-r--r--addons/web_editor/static/src/js/inline.js91
-rw-r--r--addons/web_editor/static/src/js/tours/rte.js325
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/fonts.js (renamed from addons/web_editor/static/src/js/base.js)60
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/options.js6
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/abstract.js222
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js565
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/buttons.js88
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/codeview.js60
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js210
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/editor.js189
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/font.js559
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js31
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js27
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/helper.js1843
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/hint.js139
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/history.js81
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js1182
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/link.js479
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/media.js1232
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/plugins.js78
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/table.js244
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/text.js799
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/toolbar.js57
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/transform.js85
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js360
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/plugin_registry.js43
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/translation.js161
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js64
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js563
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/crop_dialog.js218
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js58
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js219
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media.js (renamed from addons/web_editor/static/src/js/widgets/widgets.js)1086
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js207
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js26
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg.js821
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js261
-rw-r--r--addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js (renamed from addons/web_editor/static/src/js/editor/snippets.editor.js)281
-rw-r--r--addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js (renamed from addons/web_editor/static/src/js/editor/snippets.options.js)96
-rw-r--r--addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js260
-rw-r--r--addons/web_editor/static/src/scss/web_editor.backend.scss90
-rw-r--r--addons/web_editor/static/src/scss/web_editor.common.scss46
-rw-r--r--addons/web_editor/static/src/scss/web_editor.ui.scss1616
-rw-r--r--addons/web_editor/static/src/scss/web_editor.variables.scss6
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg.scss522
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg_iframe.scss24
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg_snippets.scss872
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg_variables.scss42
-rw-r--r--addons/web_editor/static/src/xml/backend.xml14
-rw-r--r--addons/web_editor/static/src/xml/editor.xml372
-rw-r--r--addons/web_editor/static/src/xml/snippets.xml6
-rw-r--r--addons/web_editor/static/src/xml/translator.xml15
-rw-r--r--addons/web_editor/static/src/xml/wysiwyg.xml375
-rw-r--r--addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml91
65 files changed, 14442 insertions, 9478 deletions
diff --git a/addons/web_editor/static/src/js/editor/transcoder.js b/addons/web_editor/static/src/js/backend/convert_inline.js
index 85903f6b4eb..3b09bef5f31 100644
--- a/addons/web_editor/static/src/js/editor/transcoder.js
+++ b/addons/web_editor/static/src/js/backend/convert_inline.js
@@ -1,9 +1,8 @@
-odoo.define('web_editor.transcoder', function (require) {
+odoo.define('web_editor.convertInline', function (require) {
'use strict';
-var base = require('web_editor.base');
-
-var rulesCache = [];
+var fonts = require('wysiwyg.fonts');
+var FieldHtml = require('web_editor.field.html');
/**
* Returns the css rules which applies on an element, tweaked so that they are
@@ -14,8 +13,11 @@ var rulesCache = [];
*/
function getMatchedCSSRules(a) {
var i, r, k;
+ var doc = a.ownerDocument;
+ var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []);
+
if (!rulesCache.length) {
- var sheets = document.styleSheets;
+ var sheets = doc.styleSheets;
for (i = sheets.length-1 ; i >= 0 ; i--) {
var rules;
// try...catch because browser may not able to enumerate rules for cross-domain sheets
@@ -190,8 +192,8 @@ function fontToImg($editable) {
$editable.find('.fa').each(function () {
var $font = $(this);
var icon, content;
- _.find(base.fontIcons, function (font) {
- return _.find(base.getCssSelectors(font.parser), function (data) {
+ _.find(fonts.fontIcons, function (font) {
+ return _.find(fonts.getCssSelectors(font.parser), function (data) {
if ($font.is(data.selector.replace(/::?before/g, ''))) {
icon = data.names[0].split('-').shift();
content = data.css.match(/content:\s*['"]?(.)['"]?/)[1];
@@ -265,9 +267,6 @@ function applyOverDescendants(node, func) {
* @param {jQuery} $editable
*/
function classToStyle($editable) {
- if (!rulesCache.length) {
- getMatchedCSSRules($editable[0]);
- }
applyOverDescendants($editable[0], function (node) {
var $target = $(node);
var css = getMatchedCSSRules(node);
@@ -328,9 +327,7 @@ function styleToClass($editable) {
$(this).after($('a,img', this));
}).remove();
- getMatchedCSSRules($editable[0]);
-
- var $c = $('<span/>').appendTo(document.body);
+ var $c = $('<span/>').appendTo($editable[0].ownerDocument.body);
applyOverDescendants($editable[0], function (node) {
var $target = $(node);
@@ -342,7 +339,7 @@ function styleToClass($editable) {
}
});
css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/);
- style = $target.attr('style') || '';
+ style = ($target.attr('style') || '').replace(/\s*:\s*/, ':').replace(/\s*;\s*/, ';');
_.each(css, function (v) {
style = style.replace(v, '');
});
@@ -364,13 +361,13 @@ function styleToClass($editable) {
* @param {jQuery} $editable
*/
function attachmentThumbnailToLinkImg($editable) {
- $editable.find('a[href*="/web/content/"][data-mimetype]:empty').each(function () {
+ $editable.find('a[href*="/web/content/"][data-mimetype]').filter(':empty, :containsExact( )').each(function () {
var $link = $(this);
var $img = $('<img/>')
.attr('src', $link.css('background-image').replace(/(^url\(['"])|(['"]\)$)/g, ''))
.css('height', Math.max(1, $link.height()) + 'px')
.css('width', Math.max(1, $link.width()) + 'px');
- $link.append($img);
+ $link.prepend($img);
});
}
@@ -384,6 +381,86 @@ function linkImgToAttachmentThumbnail($editable) {
$editable.find('a[href*="/web/content/"][data-mimetype] > img').remove();
}
+
+//--------------------------------------------------------------------------
+//--------------------------------------------------------------------------
+
+
+FieldHtml.include({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ commitChanges: function () {
+ if (!this.wysiwyg) {
+ return this._super();
+ }
+ if (this.nodeOptions['style-inline']) {
+ this._toInline();
+ }
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts CSS dependencies to CSS-independent HTML.
+ * - CSS display for attachment link -> real image
+ * - Font icons -> images
+ * - CSS styles -> inline styles
+ *
+ * @private
+ */
+ _toInline: function () {
+ var $editable = this.wysiwyg.getEditable();
+ var html = this.wysiwyg.getValue();
+ $editable.html(html);
+
+ attachmentThumbnailToLinkImg($editable);
+ fontToImg($editable);
+ classToStyle($editable);
+ this.wysiwyg.setValue($editable.html(), {
+ notifyChange: false,
+ });
+ },
+ /**
+ * Revert _toInline changes.
+ *
+ * @private
+ */
+ _fromInline: function () {
+ var $editable = this.wysiwyg.getEditable();
+ var html = this.wysiwyg.getValue();
+ $editable.html(html);
+
+ styleToClass($editable);
+ imgToFont($editable);
+ linkImgToAttachmentThumbnail($editable);
+ this.wysiwyg.setValue($editable.html(), {
+ notifyChange: false,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onLoadWysiwyg: function () {
+ if (this.nodeOptions['style-inline']) {
+ this._fromInline();
+ }
+ this._super();
+ },
+});
+
return {
fontToImg: fontToImg,
imgToFont: imgToFont,
diff --git a/addons/web_editor/static/src/js/backend/field_html.js b/addons/web_editor/static/src/js/backend/field_html.js
new file mode 100644
index 00000000000..69e32fb815e
--- /dev/null
+++ b/addons/web_editor/static/src/js/backend/field_html.js
@@ -0,0 +1,476 @@
+odoo.define('web_editor.field.html', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var basic_fields = require('web.basic_fields');
+var core = require('web.core');
+var Wysiwyg = require('web_editor.wysiwyg');
+var field_registry = require('web.field_registry');
+
+var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin;
+
+var QWeb = core.qweb;
+
+/**
+ * FieldHtml Widget
+ * Intended to display HTML content. This widget uses the wysiwyg editor
+ * improved by odoo.
+ *
+ * nodeOptions:
+ * - style-inline => convert class to inline style (no re-edition) => for sending by email
+ * - no-attachment
+ * - cssEdit
+ * - cssReadonly
+ * - snippets
+ * - wrapper
+ */
+var FieldHtml = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
+ className: 'oe_form_field oe_form_field_html',
+ supportedFieldTypes: ['html'],
+
+ custom_events: {
+ wysiwyg_focus: '_onWysiwygFocus',
+ wysiwyg_blur: '_onWysiwygBlur',
+ wysiwyg_change: '_onChange',
+ wysiwyg_attachment: '_onAttachmentChange',
+ },
+
+ /**
+ * @override
+ */
+ willStart: function () {
+ this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml');
+ var defAsset = this.nodeOptions.cssReadonly && ajax.loadAsset(this.nodeOptions.cssReadonly);
+ return $.when(this._super().then(Wysiwyg.prepare.bind(Wysiwyg, this)), defAsset);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ delete window.top[this._onUpdateIframeId];
+ if (this.$iframe) {
+ this.$iframe.remove();
+ }
+ this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ activate: function (options) {
+ if (this.wysiwyg) {
+ this.wysiwyg.focus();
+ return true;
+ }
+ },
+ /**
+ * Wysiwyg doesn't notify for changes done in code mode. We override
+ * commitChanges to manually switch back to normal mode before committing
+ * changes, so that the widget is aware of the changes done in code mode.
+ *
+ * @override
+ */
+ commitChanges: function () {
+ var self = this;
+ if (!this.wysiwyg) {
+ return this._super();
+ }
+ var _super = this._super.bind(this);
+ return this.wysiwyg.save().then(function (isDirty, html) {
+ self._isDirty = isDirty;
+ _super();
+ });
+ },
+ /**
+ * @override
+ */
+ isSet: function () {
+ return this.value && this.value !== "<p><br/></p>" && this.value.match(/\S/);
+ },
+ /**
+ * @override
+ */
+ getFocusableElement: function () {
+ return this.$target || $();
+ },
+ /**
+ * Do not re-render this field if it was the origin of the onchange call.
+ *
+ * @override
+ */
+ reset: function (record, event) {
+ this._reset(record, event);
+ var value = this.value;
+ if (this.nodeOptions.wrapper) {
+ value = this._wrap(value);
+ }
+ value = this._textToHtml(value);
+ if (!event || event.target !== this) {
+ if (this.mode === 'edit') {
+ this.wysiwyg.setValue(value);
+ } else {
+ this.$content.html(value);
+ }
+ }
+ return $.when();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getValue: function () {
+ var value = this.$target.val();
+ if (this.nodeOptions.wrapper) {
+ return this._unWrap(value);
+ }
+ return value;
+ },
+ /**
+ * Create the wysiwyg instance with the target (this.$target)
+ * then add the editable content (this.$content).
+ *
+ * @private
+ * @returns {$.Promise}
+ */
+ _createWysiwygIntance: function () {
+ var self = this;
+ this.wysiwyg = new Wysiwyg(this, this._getWysiwygOptions());
+
+ // by default this is synchronous because the assets are already loaded in willStart
+ // but it can be async in the case of options such as iframe, snippets...
+ return this.wysiwyg.attachTo(this.$target).then(function () {
+ self.$content = self.wysiwyg.$el;
+ self._onLoadWysiwyg();
+ });
+ },
+ /**
+ * Get wysiwyg options to create wysiwyg instance.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getWysiwygOptions: function () {
+ return {
+ recordInfo: {
+ context: this.record.getContext(this.recordParams),
+ res_model: this.model,
+ res_id: this.res_id,
+ },
+ noAttachment: this.nodeOptions['no-attachment'],
+ inIframe: !!this.nodeOptions.cssEdit,
+ iframeCssAssets: this.nodeOptions.cssEdit,
+ snippets: this.nodeOptions.snippets,
+
+ tabSize: 0,
+ keyMap: {
+ pc: {
+ 'TAB': null,
+ 'SHIFT+TAB': null,
+ },
+ mac: {
+ 'TAB': null,
+ 'SHIFT+TAB': null,
+ },
+ },
+ generateOptions: function (options) {
+ var para = _.find(options.toolbar, function (item) {
+ return item[0] === 'para';
+ });
+ para[1].splice(2, 0, 'checklist');
+ return options;
+ },
+ };
+ },
+ /**
+ * trigger_up 'field_changed' add record into the "ir.attachment" field found in the view.
+ * This method is called when an image is uploaded via the media dialog.
+ *
+ * For e.g. when sending email, this allows people to add attachments with the content
+ * editor interface and that they appear in the attachment list.
+ * The new documents being attached to the email, they will not be erased by the CRON
+ * when closing the wizard.
+ *
+ * @private
+ * @param {Object} attachments
+ */
+ _onAttachmentChange: function (attachments) {
+ if (!this.fieldNameAttachment) {
+ return;
+ }
+ this.trigger_up('field_changed', {
+ dataPointID: this.dataPointID,
+ changes: _.object([this.fieldNameAttachment], [{
+ operation: 'ADD_M2M',
+ ids: attachments
+ }])
+ });
+ },
+ /**
+ * @override
+ */
+ _renderEdit: function () {
+ var value = this._textToHtml(this.value);
+ if (this.nodeOptions.wrapper) {
+ value = this._wrap(value);
+ }
+ this.$target = $('<textarea>').val(value).hide();
+ this.$target.appendTo(this.$el);
+
+ var fieldNameAttachment = _.chain(this.recordData)
+ .pairs()
+ .find(function (value) {
+ return _.isObject(value[1]) && value[1].model === "ir.attachment";
+ })
+ .first()
+ .value();
+ if (fieldNameAttachment) {
+ this.fieldNameAttachment = fieldNameAttachment;
+ }
+
+ if (this.nodeOptions.cssEdit) {
+ // must be async because the target must be append in the DOM
+ this._createWysiwygIntance();
+ } else {
+ return this._createWysiwygIntance();
+ }
+ },
+ /**
+ * @override
+ */
+ _renderReadonly: function () {
+ var self = this;
+ var value = this._textToHtml(this.value);
+ if (this.nodeOptions.wrapper) {
+ value = this._wrap(value);
+ }
+
+ this.$el.empty();
+
+ var def = $.Deferred();
+ if (this.nodeOptions.cssReadonly) {
+ this.$iframe = $('<iframe class="o_readonly"/>');
+ this.$iframe.appendTo(this.$el);
+
+ var avoidDoubleLoad = 0; // this bug only appears on some computers with some chrome version.
+
+ // inject content in iframe
+
+ this.$iframe.data('load-def', def); // for unit test
+ window.top[this._onUpdateIframeId] = function (_avoidDoubleLoad) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg iframe double load detected');
+ return;
+ }
+ self.$content = $('#iframe_target', self.$iframe[0].contentWindow.document.body);
+ def.resolve();
+ };
+
+ this.$iframe.one('load', function onLoad() {
+ var _avoidDoubleLoad = ++avoidDoubleLoad;
+ ajax.loadAsset(self.nodeOptions.cssReadonly).then(function (asset) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg immediate iframe double load detected');
+ return;
+ }
+ var cwindow = self.$iframe[0].contentWindow;
+ cwindow.document
+ .open("text/html", "replace")
+ .write(
+ '<head>' +
+ '<meta charset="utf-8"/>' +
+ '<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' +
+ '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' +
+ _.map(asset.cssLibs, function (cssLib) {
+ return '<link type="text/css" rel="stylesheet" href="' + cssLib + '"/>';
+ }).join('\n') + '\n' +
+ _.map(asset.cssContents, function (cssContent) {
+ return '<style type="text/css">' + cssContent + '</style>';
+ }).join('\n') + '\n' +
+ '</head>\n' +
+ '<body class="o_in_iframe o_readonly">\n' +
+ '<div id="iframe_target">' + value + '</div>\n' +
+ '<script type="text/javascript">' +
+ 'if (window.top.' + self._onUpdateIframeId + ') {' +
+ 'window.top.' + self._onUpdateIframeId + '(' + _avoidDoubleLoad + ')' +
+ '}' +
+ '</script>\n' +
+ '</body>');
+
+ var height = cwindow.document.body.scrollHeight;
+ self.$iframe.css('height', Math.max(30, Math.min(height, 500)) + 'px');
+ });
+ });
+ } else {
+ this.$content = $('<div class="o_readonly"/>').html(value);
+ this.$content.appendTo(this.$el);
+ def.resolve();
+ }
+
+ def.then(function () {
+ self.$content.on('click', 'ul.o_checklist > li', self._onReadonlyClickChecklist.bind(self));
+ });
+ },
+ /**
+ * @private
+ * @param {string} text
+ * @returns {string} the text converted to html
+ */
+ _textToHtml: function (text) {
+ var value = text || "";
+ if (/%\send/.test(value)) { // is jinja
+ return value;
+ }
+ try {
+ $(text)[0].innerHTML; // crashes if text isn't html
+ } catch (e) {
+ if (value.match(/^\s*$/)) {
+ value = '<p><br/></p>';
+ } else {
+ value = "<p>" + value.split(/<br\/?>/).join("<br/></p><p>") + "</p>";
+ value = value
+ .replace(/<p><\/p>/g, '')
+ .replace('<p><p>', '<p>')
+ .replace('<p><p ', '<p ')
+ .replace('</p></p>', '</p>');
+ }
+ }
+ return value;
+ },
+ /**
+ * Move HTML contents out of their wrapper.
+ *
+ * @private
+ * @param {string} html content
+ * @returns {string} html content
+ */
+ _unWrap: function (html) {
+ var $wrapper = $(html).find('#wrapper');
+ return $wrapper.length ? $wrapper.html() : html;
+ },
+ /**
+ * Wrap HTML in order to create a custom display.
+ *
+ * The wrapper (this.nodeOptions.wrapper) must be a static
+ * XML template with content id="wrapper".
+ *
+ * @private
+ * @param {string} html content
+ * @returns {string} html content
+ */
+ _wrap: function (html) {
+ return $(QWeb.render(this.nodeOptions.wrapper))
+ .find('#wrapper').html(html)
+ .end().prop('outerHTML');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * Method called when wysiwyg triggers a change.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onChange: function (ev) {
+ this._doDebouncedAction.apply(this, arguments);
+
+ var $lis = this.$content.find('.note-editable ul.o_checklist > li:not(:has(> ul.o_checklist))');
+ if (!$lis.length) {
+ return;
+ }
+ var max = 0;
+ var ids = [];
+ $lis.map(function () {
+ var checklistId = parseInt(($(this).attr('id') || '0').replace(/^checklist-id-/, ''));
+ if (ids.indexOf(checklistId) === -1) {
+ if (checklistId > max) {
+ max = checklistId;
+ }
+ ids.push(checklistId);
+ } else {
+ $(this).removeAttr('id');
+ }
+ });
+ $lis.not('[id]').each(function () {
+ $(this).attr('id', 'checklist-id-' + (++max));
+ });
+ },
+ /**
+ * Method called when wysiwyg triggers a change.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onReadonlyClickChecklist: function (ev) {
+ var self = this;
+ if (ev.offsetX > 0) {
+ return;
+ }
+ var checked = $(ev.target).hasClass('o_checked');
+ var checklistId = parseInt(($(ev.target).attr('id') || '0').replace(/^checklist-id-/, ''));
+
+ this._rpc({
+ route: '/web_editor/checklist',
+ params: {
+ res_model: this.model,
+ res_id: this.res_id,
+ filename: this.name,
+ checklistId: checklistId,
+ checked: !checked,
+ },
+ }).then(function (value) {
+ self._setValue(value);
+ });
+ },
+ /**
+ * Method called when the wysiwyg instance is loaded.
+ *
+ * @private
+ */
+ _onLoadWysiwyg: function () {
+ var $button = this._renderTranslateButton();
+ $button.css({
+ 'font-size': '15px',
+ position: 'absolute',
+ right: '+5px',
+ });
+ var $toolbar = this.$content.closest('.note-editor').find('.note-toolbar');
+ $toolbar.css('position', 'relative');
+ $toolbar.append($button);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onWysiwygBlur: function (ev) {
+ ev.stopPropagation();
+ this._doAction();
+ if (ev.data.key === 'TAB') {
+ this.trigger_up('navigation_move', {
+ direction: ev.data.shiftKey ? 'left' : 'right',
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onWysiwygFocus: function (ev) {},
+});
+
+
+field_registry.add('html', FieldHtml);
+
+
+return FieldHtml;
+});
diff --git a/addons/web_editor/static/src/js/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js
deleted file mode 100644
index 7742f49bcde..00000000000
--- a/addons/web_editor/static/src/js/backend/fields.js
+++ /dev/null
@@ -1,532 +0,0 @@
-odoo.define('web_editor.backend', function (require) {
-'use strict';
-
-var AbstractField = require('web.AbstractField');
-var basic_fields = require('web.basic_fields');
-var config = require('web.config');
-var core = require('web.core');
-var session = require('web.session');
-var field_registry = require('web.field_registry');
-var SummernoteManager = require('web_editor.rte.summernote');
-var transcoder = require('web_editor.transcoder');
-
-var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin;
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-
-/**
- * FieldTextHtmlSimple Widget
- * Intended to display HTML content. This widget uses the summernote editor
- * improved by odoo.
- *
- */
-var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMixin, {
- className: 'oe_form_field oe_form_field_html_text',
- supportedFieldTypes: ['html'],
-
- /**
- * @override
- */
- start: function () {
- new SummernoteManager(this);
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Summernote doesn't notify for changes done in code mode. We override
- * commitChanges to manually switch back to normal mode before committing
- * changes, so that the widget is aware of the changes done in code mode.
- *
- * @override
- */
- commitChanges: function () {
- // switch to WYSIWYG mode if currently in code mode to get all changes
- if (config.debug && this.mode === 'edit') {
- var layoutInfo = this.$textarea.data('layoutInfo');
- $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false);
- }
- if (this._getValue() !== this.value) {
- this._isDirty = true;
- }
- this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- isSet: function () {
- return this.value && this.value !== "<p><br/></p>" && this.value.match(/\S/);
- },
- /**
- * @override
- */
- getFocusableElement: function () {
- return this.$content || this._super.apply(this, arguments);
- },
- /**
- * Do not re-render this field if it was the origin of the onchange call.
- *
- * @override
- */
- reset: function (record, event) {
- this._reset(record, event);
- if (!event || event.target !== this) {
- if (this.mode === 'edit') {
- this.$content.html(this._textToHtml(this.value));
- } else {
- this._renderReadonly();
- }
- }
- return $.when();
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Returns the domain for attachments used in media dialog.
- * We look for attachments related to the current document. If there is a value for the model
- * field, it is used to search attachments, and the attachments from the current document are
- * filtered to display only user-created documents.
- * In the case of a wizard such as mail, we have the documents uploaded and those of the model
- *
- * @private
- * @returns {Array} "ir.attachment" odoo domain.
- */
- _getAttachmentsDomain: function () {
- var domain = ['|', ['id', 'in', _.pluck(this.attachments, 'id')]];
- var attachedDocumentDomain = [
- '&',
- ['res_model', '=', this.model],
- ['res_id', '=', this.res_id|0]
- ];
- // if the document is not yet created, do not see the documents of other users
- if (!this.res_id) {
- attachedDocumentDomain.unshift('&');
- attachedDocumentDomain.push(['create_uid', '=', session.uid]);
- }
- if (this.recordData.res_model || this.recordData.model) {
- var relatedDomain = ['&',
- ['res_model', '=', this.recordData.res_model || this.recordData.model],
- ['res_id', '=', this.recordData.res_id|0]];
- if (!this.recordData.res_id) {
- relatedDomain.unshift('&');
- relatedDomain.push(['create_uid', '=', session.uid]);
- }
- domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain);
- } else {
- domain = domain.concat(attachedDocumentDomain);
- }
- return domain;
- },
- /**
- * @private
- * @returns {Object} the summernote configuration
- */
- _getSummernoteConfig: function () {
- var summernoteConfig = {
- model: this.model,
- id: this.res_id,
- focus: false,
- height: 180,
- toolbar: [
- ['style', ['style']],
- ['font', ['bold', 'italic', 'underline', 'clear']],
- ['fontsize', ['fontsize']],
- ['color', ['color']],
- ['para', ['ul', 'ol', 'paragraph']],
- ['table', ['table']],
- ['insert', this.nodeOptions['no-attachment'] ? ['link'] : ['link', 'picture']],
- ['history', ['undo', 'redo']]
- ],
- prettifyHtml: false,
- styleWithSpan: false,
- inlinemedia: ['p'],
- lang: "odoo",
- onChange: this._doDebouncedAction.bind(this),
- disableDragAndDrop: !!this.nodeOptions['no-attachment'],
- };
-
- var fieldNameAttachment =_.chain(this.recordData)
- .pairs()
- .find(function (value) {
- return _.isObject(value[1]) && value[1].model === "ir.attachment";
- })
- .first()
- .value();
-
- if (fieldNameAttachment) {
- this.fieldNameAttachment = fieldNameAttachment;
- this.attachments = [];
- summernoteConfig.onUpload = this._onUpload.bind(this);
- }
- summernoteConfig.getMediaDomain = this._getAttachmentsDomain.bind(this);
-
-
- if (config.debug) {
- summernoteConfig.toolbar.splice(7, 0, ['view', ['codeview']]);
- }
- return summernoteConfig;
- },
- /**
- * @override
- * @private
- */
- _getValue: function () {
- if (this.nodeOptions['style-inline']) {
- transcoder.attachmentThumbnailToLinkImg(this.$content);
- transcoder.fontToImg(this.$content);
- transcoder.classToStyle(this.$content);
- }
- return this.$content.html();
- },
- /**
- * trigger_up 'field_changed' add record into the "ir.attachment" field found in the view.
- * This method is called when an image is uploaded by the media dialog.
- *
- * For e.g. when sending email, this allows people to add attachments with the content
- * editor interface and that they appear in the attachment list.
- * The new documents being attached to the email, they will not be erased by the CRON
- * when closing the wizard.
- *
- * @private
- */
- _onUpload: function (attachments) {
- var self = this;
- attachments = _.filter(attachments, function (attachment) {
- return !_.findWhere(self.attachments, {id: attachment.id});
- });
- if (!attachments.length) {
- return;
- }
- this.attachments = this.attachments.concat(attachments);
- this.trigger_up('field_changed', {
- dataPointID: this.dataPointID,
- changes: _.object([this.fieldNameAttachment], [{
- operation: 'ADD_M2M',
- ids: attachments
- }])
- });
- },
- /**
- * @override
- * @private
- */
- _renderEdit: function () {
- this.$textarea = $('<textarea>');
- this.$textarea.appendTo(this.$el);
- this.$textarea.summernote(this._getSummernoteConfig());
- this.$content = this.$('.note-editable:first');
- this.$content.html(this._textToHtml(this.value));
- // trigger a mouseup to refresh the editor toolbar
- var mouseupEvent = $.Event('mouseup', {'setStyleInfoFromEditable': true});
- this.$content.trigger(mouseupEvent);
- if (this.nodeOptions['style-inline']) {
- transcoder.styleToClass(this.$content);
- transcoder.imgToFont(this.$content);
- transcoder.linkImgToAttachmentThumbnail(this.$content);
- }
- // reset the history (otherwise clicking on undo before editing the
- // value will empty the editor)
- var history = this.$content.data('NoteHistory');
- if (history) {
- history.reset();
- }
- this.$('.note-toolbar').append(this._renderTranslateButton());
- },
- /**
- * @override
- * @private
- */
- _renderReadonly: function () {
- var self = this;
- this.$el.empty();
- if (this.nodeOptions['style-inline']) {
- var $iframe = $('<iframe class="o_readonly"/>');
- $iframe.on('load', function () {
- self.$content = $($iframe.contents()[0]).find("body");
- self.$content.html(self._textToHtml(self.value));
- self._resize();
- });
- $iframe.appendTo(this.$el);
- } else {
- this.$content = $('<div class="o_readonly"/>');
- this.$content.html(this._textToHtml(this.value));
- this.$content.appendTo(this.$el);
- }
- },
- /**
- * Sets the height of the iframe.
- *
- * @private
- */
- _resize: function () {
- var height = this.$content[0] ? this.$content[0].scrollHeight : 0;
- this.$('iframe').css('height', Math.max(30, Math.min(height, 500)) + 'px');
- },
- /**
- * @private
- * @param {string} text
- * @returns {string} the text converted to html
- */
- _textToHtml: function (text) {
- var value = text || "";
- try {
- $(text)[0].innerHTML; // crashes if text isn't html
- } catch (e) {
- if (value.match(/^\s*$/)) {
- value = '<p><br/></p>';
- } else {
- value = "<p>" + value.split(/<br\/?>/).join("<br/></p><p>") + "</p>";
- value = value
- .replace(/<p><\/p>/g, '')
- .replace('<p><p>', '<p>')
- .replace('<p><p ', '<p ')
- .replace('</p></p>', '</p>');
- }
- }
- return value;
- },
- /**
- * @override
- * @private
- * @returns {jQueryElement}
- */
- _renderTranslateButton: function () {
- if (_t.database.multi_lang && this.field.translate && this.res_id) {
- return $(QWeb.render('web_editor.FieldTextHtml.button.translate', {widget: this}))
- .on('click', this._onTranslate.bind(this));
- }
- return $();
- },
-
-});
-
-var FieldTextHtml = AbstractField.extend({
- template: 'web_editor.FieldTextHtml',
- supportedFieldTypes: ['html'],
-
- start: function () {
- var self = this;
-
- this.editorLoadedDeferred = $.Deferred();
- this.contentLoadedDeferred = $.Deferred();
- this.callback = _.uniqueId('FieldTextHtml_');
- window.odoo[this.callback+"_editor"] = function (EditorBar) {
- setTimeout(function () {
- self.on_editor_loaded(EditorBar);
- },0);
- };
- window.odoo[this.callback+"_content"] = function () {
- self.on_content_loaded();
- };
- window.odoo[this.callback+"_updown"] = null;
- window.odoo[this.callback+"_downup"] = function () {
- self.resize();
- };
-
- // init jqery objects
- this.$iframe = this.$el.find('iframe');
- this.document = null;
- this.$body = $();
- this.$content = $();
-
- this.$iframe.css('min-height', 'calc(100vh - 360px)');
-
- // init resize
- this.resize = function resize() {
- if (self.mode === 'edit') {
- if ($("body").hasClass("o_field_widgetTextHtml_fullscreen")) {
- self.$iframe.css('height', (document.body.clientHeight - self.$iframe.offset().top) + 'px');
- } else {
- self.$iframe.css("height", (self.$body.find("#oe_snippets").length ? 500 : 300) + "px");
- }
- }
- };
- $(window).on('resize', this.resize);
-
- this.old_initialize_content();
- var def = this._super.apply(this, arguments);
- return def;
- },
- getDatarecord: function () {
- return this.recordData;
- },
- get_url: function (_attr) {
- var src = this.nodeOptions.editor_url || "/mass_mailing/field/email_template";
- var k;
- var datarecord = this.getDatarecord();
- var attr = {
- 'model': this.model,
- 'field': this.name,
- 'res_id': datarecord.id || '',
- 'callback': this.callback
- };
- _attr = _attr || {};
-
- if (this.nodeOptions['style-inline']) {
- attr.inline_mode = 1;
- }
- if (this.nodeOptions.snippets) {
- attr.snippets = this.nodeOptions.snippets;
- }
- if (this.nodeOptions.template) {
- attr.template = this.nodeOptions.template;
- }
- if (this.mode === "edit") {
- attr.enable_editor = 1;
- }
- if (session.debug) {
- attr.debug = session.debug;
- }
-
- for (k in _attr) {
- attr[k] = _attr[k];
- }
-
- if (src.indexOf('?') === -1) {
- src += "?";
- }
-
- for (k in attr) {
- if (attr[k] !== null) {
- src += "&"+k+"="+(_.isBoolean(attr[k]) ? +attr[k] : attr[k]);
- }
- }
-
- // delete datarecord[this.name];
- src += "&datarecord="+ encodeURIComponent(JSON.stringify(datarecord));
- return src;
- },
- old_initialize_content: function () {
- this.$el.closest('.modal-body').css('max-height', 'none');
- this.$iframe = this.$el.find('iframe');
- this.document = null;
- this.$body = $();
- this.$content = $();
- this.editor = false;
- window.odoo[this.callback+"_updown"] = null;
- this.$iframe.attr("src", this.get_url());
- },
- on_content_loaded: function () {
- var self = this;
- this.document = this.$iframe.contents()[0];
- this.$body = $("body", this.document);
- this.$content = this.$body.find("#editable_area");
- this.render();
- this.add_button();
- this.contentLoadedDeferred.resolve();
- setTimeout(self.resize, 0);
- },
- on_editor_loaded: function (EditorBar) {
- var self = this;
- this.editor = EditorBar;
- if (this.value && window.odoo[self.callback+"_updown"] && !(this.$content.html()||"").length) {
- this.render();
- }
- this.editorLoadedDeferred.resolve();
- setTimeout(function () {
- setTimeout(self.resize,0);
- }, 0);
- },
- add_button: function () {
- var self = this;
- var $to = this.$body.find("#web_editor-top-edit, #wrapwrap").first();
-
- $(QWeb.render('web_editor.FieldTextHtml.fullscreen'))
- .appendTo($to)
- .on('click', '.o_fullscreen', function () {
- $("body").toggleClass("o_field_widgetTextHtml_fullscreen");
- var full = $("body").hasClass("o_field_widgetTextHtml_fullscreen");
- self.$iframe.parents().toggleClass('o_form_fullscreen_ancestor', full);
- $(window).trigger("resize"); // induce a resize() call and let other backend elements know (the navbar extra items management relies on this)
- });
-
- this.$body.on('click', '[data-action="cancel"]', function (event) {
- event.preventDefault();
- self.old_initialize_content();
- });
- },
- render: function () {
- var value = (this.value || "").replace(/^<p[^>]*>(\s*|<br\/?>)<\/p>$/, '');
- if (!this.$content) {
- return;
- }
- if (this.mode === "edit") {
- if (window.odoo[this.callback+"_updown"]) {
- // FIXME
- // window.odoo[this.callback+"_updown"](value, this.view.get_fields_values(), this.name);
- this.resize();
- }
- } else {
- this.$content.html(value);
- if (this.$iframe[0].contentWindow) {
- this.$iframe.css("height", (this.$body.height()+20) + "px");
- }
- }
- },
- has_no_value: function () {
- return this.value === false || !this.$content.html() || !this.$content.html().match(/\S/);
- },
- destroy: function () {
- $(window).off('resize', this.resize);
- delete window.odoo[this.callback+"_editor"];
- delete window.odoo[this.callback+"_content"];
- delete window.odoo[this.callback+"_updown"];
- delete window.odoo[this.callback+"_downup"];
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Set the value when the widget is fully loaded (content + editor).
- *
- * @override
- */
- commitChanges: function () {
- var self = this;
- var result = this._super.bind(this, arguments);
- if (this.mode === 'readonly') {
- return;
- }
- return $.when(this.contentLoadedDeferred, this.editorLoadedDeferred, result).then(function () {
- // switch to WYSIWYG mode if currently in code mode to get all changes
- if (config.debug && self.editor.rte) {
- var layoutInfo = self.editor.rte.editable().data('layoutInfo');
- $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false);
- }
- var $ancestors = self.$iframe.filter(':not(:visible)').parentsUntil(':visible').addBack();
- var ancestorsStyle = [];
- // temporarily force displaying iframe (needed for firefox)
- _.each($ancestors, function (el) {
- var $el = $(el);
- ancestorsStyle.unshift($el.attr('style') || null);
- $el.css({display: 'initial', visibility: 'hidden', height: 1});
- });
- self.editor.snippetsMenu && self.editor.snippetsMenu.cleanForSave();
- _.each($ancestors, function (el) {
- var $el = $(el);
- $el.attr('style', ancestorsStyle.pop());
- });
- self._setValue(self.$content.html());
- });
- },
-});
-
-field_registry
- .add('html', FieldTextHtmlSimple)
- .add('html_frame', FieldTextHtml);
-
-return {
- FieldTextHtmlSimple: FieldTextHtmlSimple,
- FieldTextHtml: FieldTextHtml,
-};
-});
diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/common/ace.js
index f3c47f30050..2379a938d1f 100644
--- a/addons/web_editor/static/src/js/widgets/ace.js
+++ b/addons/web_editor/static/src/js/common/ace.js
@@ -5,7 +5,6 @@ var ajax = require('web.ajax');
var core = require('web.core');
var Dialog = require('web.Dialog');
var Widget = require('web.Widget');
-var weContext = require('web_editor.context');
var localStorage = require('web.local_storage');
var session = require('web.session');
@@ -157,6 +156,8 @@ var ViewEditor = Widget.extend({
init: function (parent, viewKey, options) {
this._super.apply(this, arguments);
+ this.context = options.context;
+
this.viewKey = viewKey;
this.options = _.defaults({}, options, {
position: 'right',
diff --git a/addons/web_editor/static/src/js/content/body_manager.js b/addons/web_editor/static/src/js/content/body_manager.js
deleted file mode 100644
index 1045e94a65a..00000000000
--- a/addons/web_editor/static/src/js/content/body_manager.js
+++ /dev/null
@@ -1,60 +0,0 @@
-odoo.define('web_editor.BodyManager', function (require) {
-'use strict';
-
-var weContext = require('web_editor.context');
-var rootWidget = require('web_editor.root_widget');
-var ServiceProviderMixin = require('web.ServiceProviderMixin');
-var session = require('web.session');
-
-/**
- * Element which is designed to be unique and that will be the top-most element
- * in the widget hierarchy. So, all other widgets will be indirectly linked to
- * this Class instance. Its main role will be to retrieve RPC demands from its
- * children and handle them.
- */
-var BodyManager = rootWidget.RootWidget.extend(ServiceProviderMixin, {
- /**
- * @constructor
- */
- init: function () {
- this._super.apply(this, arguments);
- ServiceProviderMixin.init.call(this);
- },
- /**
- * @override
- */
- willStart: function () {
- return $.when(
- this._super.apply(this, arguments),
- session.is_bound
- );
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Automatically add the web_editor context.
- *
- * @override
- */
- _call_service: function (event) {
- if (event.data.service === 'ajax' && event.data.method === 'rpc') {
- var route = event.data.args[0];
- if (_.str.startsWith(route, '/web/dataset/call_kw/')) {
- var params = event.data.args[1];
- var options = event.data.args[2];
- params.kwargs.context = _.extend({}, weContext.get(), params.kwargs.context || {});
- if (options) {
- params.kwargs.context = _.omit(params.kwargs.context, options.noContextKeys);
- event.data.args[2] = _.omit(options, 'noContextKeys');
- }
- params.kwargs.context = JSON.parse(JSON.stringify(params.kwargs.context));
- }
- }
- return ServiceProviderMixin._call_service.apply(this, arguments);
- },
-});
-return BodyManager;
-});
diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js
deleted file mode 100644
index 63fa8c1c667..00000000000
--- a/addons/web_editor/static/src/js/editor/editor.js
+++ /dev/null
@@ -1,286 +0,0 @@
-odoo.define('web_editor.editor', function (require) {
-'use strict';
-
-var Dialog = require('web.Dialog');
-var Widget = require('web.Widget');
-var core = require('web.core');
-var rte = require('web_editor.rte');
-var snippetsEditor = require('web_editor.snippet.editor');
-
-var _t = core._t;
-
-var EditorMenuBar = Widget.extend({
- template: 'web_editor.editorbar',
- xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
- events: {
- 'click button[data-action=save]': '_onSaveClick',
- 'click button[data-action=cancel]': '_onCancelClick',
- },
- custom_events: {
- request_history_undo_record: '_onHistoryUndoRecordRequest',
- request_save: '_onSaveRequest',
- },
-
- /**
- * Initializes RTE and snippets menu.
- *
- * @constructor
- */
- init: function (parent) {
- var self = this;
- var res = this._super.apply(this, arguments);
- this.rte = new rte.Class(this);
- this.rte.on('rte:start', this, function () {
- self.trigger('rte:start');
- });
-
- // Snippets edition
- var $editable = this.rte.editable();
- window.__EditorMenuBar_$editable = $editable; // TODO remove this hack asap
- this.snippetsMenu = new snippetsEditor.Class(this, $editable);
-
- return res;
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
- var defs = [this._super.apply(this, arguments)];
-
- core.bus.on('editor_save_request', this, this.save);
- core.bus.on('editor_discard_request', this, this.cancel);
-
- $('.dropdown-toggle').dropdown();
-
- $(document).on('keyup', function (event) {
- if ((event.keyCode === 8 || event.keyCode === 46)) {
- var $target = $(event.target).closest('.o_editable');
- if (!$target.is(':has(*:not(p):not(br))') && !$target.text().match(/\S/)) {
- $target.empty();
- }
- }
- });
- $(document).on('click', '.note-editable', function (ev) {
- ev.preventDefault();
- });
- $(document).on('submit', '.note-editable form .btn', function (ev) {
- ev.preventDefault(); // Disable form submition in editable mode
- });
- $(document).on('hide.bs.dropdown', '.dropdown', function (ev) {
- // Prevent dropdown closing when a contenteditable children is focused
- if (ev.originalEvent
- && $(ev.target).has(ev.originalEvent.target).length
- && $(ev.originalEvent.target).is('[contenteditable]')) {
- ev.preventDefault();
- }
- });
-
- this.rte.start();
-
- var flag = false;
- window.onbeforeunload = function (event) {
- if (rte.history.getEditableHasUndo().length && !flag) {
- flag = true;
- _.defer(function () { flag=false; });
- return _t('This document is not saved!');
- }
- };
-
- // Snippets menu
- defs.push(this.snippetsMenu.insertAfter(this.$el));
- this.rte.editable().find('*').off('mousedown mouseup click');
-
- return $.when.apply($, defs).then(function () {
- self.trigger_up('edit_mode');
- });
- },
- /**
- * @override
- */
- destroy: function () {
- this._super.apply(this, arguments);
- core.bus.off('editor_save_request', this, this._onSaveRequest);
- core.bus.off('editor_discard_request', this, this._onDiscardRequest);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Asks the user if he really wants to discard its changes (if there are
- * some of them), then simply reload the page if he wants to.
- *
- * @param {boolean} [reload=true]
- * true if the page has to be reloaded when the user answers yes
- * (do nothing otherwise but add this to allow class extension)
- * @returns {Deferred}
- */
- cancel: function (reload) {
- var self = this;
- var def = $.Deferred();
- if (!rte.history.getEditableHasUndo().length) {
- def.resolve();
- } else {
- var confirm = Dialog.confirm(this, _t("If you discard the current edition, all unsaved changes will be lost. You can cancel to return to the edition mode."), {
- confirm_callback: def.resolve.bind(def),
- });
- confirm.on('closed', def, def.reject);
- }
- return def.then(function () {
- if (reload !== false) {
- window.onbeforeunload = null;
- return self._reload();
- }
- });
- },
- /**
- * Asks the snippets to clean themself, then saves the page, then reloads it
- * if asked to.
- *
- * @param {boolean} [reload=true]
- * true if the page has to be reloaded after the save
- * @returns {Deferred}
- */
- save: function (reload) {
- var self = this;
- var defs = [];
- this.trigger_up('ready_to_save', {defs: defs});
- return $.when.apply($, defs).then(function () {
- self.snippetsMenu.cleanForSave();
- return self._saveCroppedImages();
- }).then(function () {
- return self.rte.save();
- }).then(function () {
- if (reload !== false) {
- return self._reload();
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Reloads the page in non-editable mode, with the right scrolling.
- *
- * @private
- * @returns {Deferred} (never resolved, the page is reloading anyway)
- */
- _reload: function () {
- window.location.hash = 'scrollTop=' + window.document.body.scrollTop;
- if (window.location.search.indexOf('enable_editor') >= 0) {
- window.location.href = window.location.href.replace(/&?enable_editor(=[^&]*)?/g, '');
- } else {
- window.location.reload(true);
- }
- return $.Deferred();
- },
- /**
- * @private
- */
- _saveCroppedImages: function () {
- var self = this;
- var defs = _.map(this.rte.editable().find('.o_cropped_img_to_save'), function (croppedImg) {
- var $croppedImg = $(croppedImg);
- $croppedImg.removeClass('o_cropped_img_to_save');
-
- var resModel = $croppedImg.data('crop:resModel');
- var resID = $croppedImg.data('crop:resID');
- var cropID = $croppedImg.data('crop:id');
- var mimetype = $croppedImg.data('crop:mimetype');
- var originalSrc = $croppedImg.data('crop:originalSrc');
-
- var datas = $croppedImg.attr('src').split(',')[1];
-
- if (!cropID) {
- var name = originalSrc + '.crop';
- return self._rpc({
- model: 'ir.attachment',
- method: 'create',
- args: [{
- res_model: resModel,
- res_id: resID,
- name: name,
- datas_fname: name,
- datas: datas,
- mimetype: mimetype,
- url: originalSrc, // To save the original image that was cropped
- }],
- }).then(function (attachmentID) {
- return self._rpc({
- model: 'ir.attachment',
- method: 'generate_access_token',
- args: [[attachmentID]],
- }).then(function (access_token) {
- $croppedImg.attr('src', '/web/image/' + attachmentID + '?access_token=' + access_token[0]);
- });
- });
- } else {
- return self._rpc({
- model: 'ir.attachment',
- method: 'write',
- args: [[cropID], {datas: datas}],
- });
- }
- });
- return $.when.apply($, defs);
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the "Discard" button is clicked -> discards the changes.
- *
- * @private
- */
- _onCancelClick: function () {
- this.cancel();
- },
- /**
- * Called when an element askes to record an history undo -> records it.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onHistoryUndoRecordRequest: function (ev) {
- this.rte.historyRecordUndo(ev.data.$target, ev.data.event);
- },
- /**
- * Called when the "Save" button is clicked -> saves the changes.
- *
- * @private
- */
- _onSaveClick: function () {
- this.save();
- },
- /**
- * Called when a discard request is received -> discard the page content
- * changes.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onDiscardRequest: function (ev) {
- this.cancel(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure);
- },
- /**
- * Called when a save request is received -> saves the page content.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onSaveRequest: function (ev) {
- ev.stopPropagation();
- this.save(ev.data.reload).then(ev.data.onSuccess, ev.data.onFailure);
- },
-});
-
-return {
- Class: EditorMenuBar,
-};
-});
diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js
deleted file mode 100644
index ba6e1fed7ec..00000000000
--- a/addons/web_editor/static/src/js/editor/rte.js
+++ /dev/null
@@ -1,749 +0,0 @@
-odoo.define('web_editor.rte', function (require) {
-'use strict';
-
-var base = require('web_editor.base');
-var concurrency = require('web.concurrency');
-var core = require('web.core');
-var Widget = require('web.Widget');
-var weContext = require('web_editor.context');
-var summernote = require('web_editor.summernote');
-var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
-
-var _t = core._t;
-
-// Summernote Lib (neek change to make accessible: method and object)
-var dom = summernote.core.dom;
-var range = summernote.core.range;
-
-// Change History to have a global History for all summernote instances
-var History = function History($editable) {
- var aUndo = [];
- var pos = 0;
- var toSnap;
-
- this.makeSnap = function (event, rng) {
- rng = rng || range.create();
- var elEditable = $(rng && rng.sc).closest('.o_editable')[0];
- if (!elEditable) {
- return false;
- }
- return {
- event: event,
- editable: elEditable,
- contents: elEditable.innerHTML,
- bookmark: rng && rng.bookmark(elEditable),
- scrollTop: $(elEditable).scrollTop()
- };
- };
-
- this.applySnap = function (oSnap) {
- var $editable = $(oSnap.editable);
-
- if (document.documentMode) {
- $editable.removeAttr('contentEditable').removeProp('contentEditable');
- }
-
- $editable.trigger('content_will_be_destroyed');
- $editable.html(oSnap.contents).scrollTop(oSnap.scrollTop);
- $editable.trigger('content_was_recreated');
-
- $('.oe_overlay').remove();
- $('.note-control-selection').hide();
-
- $editable.trigger('content_changed');
-
- try {
- var r = oSnap.editable.innerHTML === '' ? range.create(oSnap.editable, 0) : range.createFromBookmark(oSnap.editable, oSnap.bookmark);
- r.select();
- } catch (e) {
- console.error(e);
- return;
- }
-
- $(document).trigger('click');
- $('.o_editable *').filter(function () {
- var $el = $(this);
- if ($el.data('snippet-editor')) {
- $el.removeData();
- }
- });
-
-
- _.defer(function () {
- var target = dom.isBR(r.sc) ? r.sc.parentNode : dom.node(r.sc);
- if (!target) {
- return;
- }
-
- $editable.trigger('applySnap');
-
- var evt = document.createEvent('MouseEvents');
- evt.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, target);
- target.dispatchEvent(evt);
-
- $editable.trigger('keyup');
- });
- };
-
- this.undo = function () {
- if (!pos) { return; }
- var _toSnap = toSnap;
- if (_toSnap) {
- this.saveSnap();
- }
- if (!aUndo[pos] && (!aUndo[pos] || aUndo[pos].event !== 'undo')) {
- var temp = this.makeSnap('undo');
- if (temp && (!pos || temp.contents !== aUndo[pos-1].contents)) {
- aUndo[pos] = temp;
- } else {
- pos--;
- }
- } else if (_toSnap) {
- pos--;
- }
- this.applySnap(aUndo[Math.max(--pos,0)]);
- while (pos && (aUndo[pos].event === 'blur' || (aUndo[pos+1].editable === aUndo[pos].editable && aUndo[pos+1].contents === aUndo[pos].contents))) {
- this.applySnap(aUndo[--pos]);
- }
- };
-
- this.hasUndo = function () {
- return (toSnap && (toSnap.event !== 'blur' && toSnap.event !== 'activate' && toSnap.event !== 'undo')) ||
- !!_.find(aUndo.slice(0, pos+1), function (undo) {
- return undo.event !== 'blur' && undo.event !== 'activate' && undo.event !== 'undo';
- });
- };
-
- this.getEditableHasUndo = function () {
- var editable = [];
- if ((toSnap && (toSnap.event !== 'blur' && toSnap.event !== 'activate' && toSnap.event !== 'undo'))) {
- editable.push(toSnap.editable);
- }
- _.each(aUndo.slice(0, pos+1), function (undo) {
- if (undo.event !== 'blur' && undo.event !== 'activate' && undo.event !== 'undo') {
- editable.push(undo.editable);
- }
- });
- return _.uniq(editable);
- };
-
- this.redo = function () {
- if (!aUndo[pos+1]) { return; }
- this.applySnap(aUndo[++pos]);
- while (aUndo[pos+1] && aUndo[pos].event === 'active') {
- this.applySnap(aUndo[pos++]);
- }
- };
-
- this.hasRedo = function () {
- return aUndo.length > pos+1;
- };
-
- this.recordUndo = function ($editable, event, internal_history) {
- var self = this;
- if (!$editable) {
- var rng = range.create();
- if (!rng) return;
- $editable = $(rng.sc).closest('.o_editable');
- }
-
- if (aUndo[pos] && (event === 'applySnap' || event === 'activate')) {
- return;
- }
-
- if (!internal_history) {
- if (!event || !toSnap || !aUndo[pos-1] || toSnap.event === 'activate') { // don't trigger change for all keypress
- setTimeout(function () {
- $editable.trigger('content_changed');
- },0);
- }
- }
-
- if (aUndo[pos]) {
- pos = Math.min(pos, aUndo.length);
- aUndo.splice(pos, aUndo.length);
- }
-
- // => make a snap when the user change editable zone (because: don't make snap for each keydown)
- if (toSnap && (toSnap.split || !event || toSnap.event !== event || toSnap.editable !== $editable[0])) {
- this.saveSnap();
- }
-
- if (pos && aUndo[pos-1].editable !== $editable[0]) {
- var snap = this.makeSnap('blur', range.create(aUndo[pos-1].editable, 0));
- pos++;
- aUndo.push(snap);
- }
-
- if (range.create()) {
- toSnap = self.makeSnap(event);
- } else {
- toSnap = false;
- }
- };
-
- this.splitNext = function () {
- if (toSnap) {
- toSnap.split = true;
- }
- };
-
- this.saveSnap = function () {
- if (toSnap) {
- if (!aUndo[pos]) {
- pos++;
- }
- aUndo.push(toSnap);
- delete toSnap.split;
- toSnap = null;
- }
- };
-};
-var history = new History();
-
-// jQuery extensions
-$.extend($.expr[':'], {
- o_editable: function (node, i, m) {
- while (node) {
- if (node.className && _.isString(node.className)) {
- if (node.className.indexOf('o_not_editable')!==-1 ) {
- return false;
- }
- if (node.className.indexOf('o_editable')!==-1 ) {
- return true;
- }
- }
- node = node.parentNode;
- }
- return false;
- },
-});
-$.fn.extend({
- focusIn: function () {
- if (this.length) {
- range.create(dom.firstChild(this[0]), 0).select();
- }
- return this;
- },
- focusInEnd: function () {
- if (this.length) {
- var last = dom.lastChild(this[0]);
- range.create(last, dom.nodeLength(last)).select();
- }
- return this;
- },
- selectContent: function () {
- if (this.length) {
- var next = dom.lastChild(this[0]);
- range.create(dom.firstChild(this[0]), 0, next, next.textContent.length).select();
- }
- return this;
- },
-});
-
-// RTE
-var RTEWidget = Widget.extend({
- /**
- * @constructor
- */
- init: function (parent, getConfig) {
- var self = this;
- this._super.apply(this, arguments);
-
- this.init_bootstrap_carousel = $.fn.carousel;
- this.edit_bootstrap_carousel = function () {
- var res = self.init_bootstrap_carousel.apply(this, arguments);
- // off bootstrap keydown event to remove event.preventDefault()
- // and allow to change cursor position
- $(this).off('keydown.bs.carousel');
- return res;
- };
-
- this._getConfig = getConfig || this._getDefaultConfig;
-
- base.computeFonts();
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
-
- this.saving_mutex = new concurrency.Mutex();
-
- $.fn.carousel = this.edit_bootstrap_carousel;
-
- $(document).on('click.rte keyup.rte', function () {
- var current_range = {};
- try {
- current_range = range.create() || {};
- } catch (e) {
- // if range is on Restricted element ignore error
- }
- var $popover = $(current_range.sc).closest('[contenteditable]');
- var popover_history = ($popover.data()||{}).NoteHistory;
- if (!popover_history || popover_history === history) return;
- var editor = $popover.parent('.note-editor');
- $('button[data-event="undo"]', editor).attr('disabled', !popover_history.hasUndo());
- $('button[data-event="redo"]', editor).attr('disabled', !popover_history.hasRedo());
- });
- $(document).on('mousedown.rte activate.rte', this, this._onMousedown.bind(this));
- $(document).on('mouseup.rte', this, this._onMouseup.bind(this));
-
- $('.o_not_editable').attr('contentEditable', false);
-
- var $editable = this.editable();
-
- // When a undo/redo is performed, the whole DOM is changed so we have
- // to prepare for it (website will restart animations for example)
- // TODO should be better handled
- $editable.on('content_will_be_destroyed', function (ev) {
- self.trigger_up('content_will_be_destroyed', {
- $target: $(ev.currentTarget),
- });
- });
- $editable.on('content_was_recreated', function (ev) {
- self.trigger_up('content_was_recreated', {
- $target: $(ev.currentTarget),
- });
- });
-
- $editable.addClass('o_editable')
- .data('rte', this)
- .each(function () {
- var $node = $(this);
-
- // fallback for firefox iframe display:none see https://github.com/odoo/odoo/pull/22610
- var computedStyles = window.getComputedStyle(this) || window.parent.getComputedStyle(this);
- // add class to display inline-block for empty t-field
- if (computedStyles.display === 'inline' && $node.data('oe-type') !== 'image') {
- $node.addClass('o_is_inline_editable');
- }
- });
-
- // start element observation
- $(document).on('content_changed', '.o_editable', function (ev) {
- self.trigger_up('rte_change', {target: ev.target});
- if (!ev.__isDirtyHandled) {
- $(this).addClass('o_dirty');
- ev.__isDirtyHandled = true;
- }
- });
-
- $('#wrapwrap, .o_editable').on('click.rte', '*', this, this._onClick.bind(this));
-
- $('body').addClass('editor_enable');
-
- $(document.body)
- .tooltip({
- selector: '[data-oe-readonly]',
- container: 'body',
- trigger: 'hover',
- delay: { 'show': 1000, 'hide': 100 },
- placement: 'bottom',
- title: _t("Readonly field")
- })
- .on('click', function () {
- $(this).tooltip('hide');
- });
-
- $(document).trigger('mousedown');
- this.trigger('rte:start');
-
- return this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- destroy: function () {
- this.cancel();
- this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Stops the RTE.
- */
- cancel: function () {
- if (this.$last) {
- this.$last.destroy();
- this.$last = null;
- }
-
- $.fn.carousel = this.init_bootstrap_carousel;
-
- $(document).off('.rte');
- $('#wrapwrap, .o_editable').off('.rte');
-
- $('.o_not_editable').removeAttr('contentEditable');
-
- $(document).off('click.rte keyup.rte mousedown.rte activate.rte mouseup.rte');
- $(document).off('content_changed').removeClass('o_is_inline_editable').removeData('rte');
- $(document).tooltip('dispose');
- $('body').removeClass('editor_enable');
- this.trigger('rte:stop');
- },
- /**
- * Returns the editable areas on the page.
- *
- * @returns {jQuery}
- */
- editable: function () {
- return $('#wrapwrap [data-oe-model]')
- .not('.o_not_editable')
- .filter(function () {
- return !$(this).closest('.o_not_editable').length;
- })
- .not('link, script')
- .not('[data-oe-readonly]')
- .not('img[data-oe-field="arch"], br[data-oe-field="arch"], input[data-oe-field="arch"]')
- .not('.oe_snippet_editor')
- .add('.o_editable');
- },
- /**
- * Records the current state of the given $target to be able to undo future
- * changes.
- *
- * @see History.recordUndo
- * @param {jQuery} $target
- * @param {string} event
- * @param {boolean} internal_history
- */
- historyRecordUndo: function ($target, event, internal_history) {
- $target = $($target);
- var rng = range.create();
- var $editable = $(rng && rng.sc).closest('.o_editable');
- if (!rng || !$editable.length) {
- $editable = $target.closest('.o_editable');
- rng = range.create($target.closest('*')[0],0);
- } else {
- rng = $editable.data('range') || rng;
- }
- try {
- // TODO this line might break for unknown reasons. I suppose that
- // the created range is an invalid one. As it might be tricky to
- // adapt that line and that it is not a critical one, temporary fix
- // is to ignore the errors that this generates.
- rng.select();
- } catch (e) {
- console.log('error', e);
- }
- history.recordUndo($editable, event, internal_history);
- },
- /**
- * Searches all the dirty element on the page and saves them one by one. If
- * one cannot be saved, this notifies it to the user and restarts rte
- * edition.
- *
- * @param {Object} [context] - the context to use for saving rpc, default to
- * the editor context found on the page
- * @return {Deferred} rejected if the save cannot be done
- */
- save: function (context) {
- var self = this;
-
- $('.o_editable')
- .destroy()
- .removeClass('o_editable o_is_inline_editable');
-
- var $dirty = $('.o_dirty');
- $dirty
- .removeAttr('contentEditable')
- .removeClass('o_dirty oe_carlos_danger o_is_inline_editable');
- var defs = _.map($dirty, function (el) {
- var $el = $(el);
-
- $el.find('[class]').filter(function () {
- if (!this.getAttribute('class').match(/\S/)) {
- this.removeAttribute('class');
- }
- });
-
- // TODO: Add a queue with concurrency limit in webclient
- // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js
- return self.saving_mutex.exec(function () {
- return self._saveElement($el, context || weContext.get())
- .then(function () {
- $el.removeClass('o_dirty');
- }, function (response) {
- // because ckeditor regenerates all the dom, we can't just
- // setup the popover here as everything will be destroyed by
- // the DOM regeneration. Add markings instead, and returns a
- // new rejection with all relevant info
- var id = _.uniqueId('carlos_danger_');
- $el.addClass('o_dirty oe_carlos_danger ' + id);
- var html = (response.data.exception_type === 'except_osv');
- if (html) {
- var msg = $('<div/>', {text: response.data.message}).html();
- var data = msg.substring(3, msg.length -2).split(/', u'/);
- response.data.message = '<b>' + data[0] + '</b>' + data[1];
- }
- $('.o_editable.' + id)
- .removeClass(id)
- .popover({
- html: html,
- trigger: 'hover',
- content: response.data.message,
- placement: 'auto top',
- })
- .popover('show');
- });
- });
- });
-
- return $.when.apply($, defs).then(function () {
- window.onbeforeunload = null;
- }, function (failed) {
- // If there were errors, re-enable edition
- self.cancel();
- self.start();
- });
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * When the users clicks on an editable element, this function allows to add
- * external behaviors.
- *
- * @private
- * @param {jQuery} $editable
- */
- _enableEditableArea: function ($editable) {
- if ($editable.data('oe-type') === "monetary") {
- $editable.attr('contenteditable', false);
- $editable.find('.oe_currency_value').attr('contenteditable', true);
- }
- if ($editable.is('[data-oe-model]') && !$editable.is('[data-oe-model="ir.ui.view"]') && !$editable.is('[data-oe-type="html"]')) {
- $editable.data('layoutInfo').popover().find('.btn-group:not(.note-history)').remove();
- }
- },
- /**
- * When an element enters edition, summernote is initialized on it. This
- * function returns the default configuration for the summernote instance.
- *
- * @see _getConfig
- * @private
- * @param {jQuery} $editable
- * @returns {Object}
- */
- _getDefaultConfig: function ($editable) {
- return {
- 'airMode' : true,
- 'focus': false,
- 'airPopover': [
- ['style', ['style']],
- ['font', ['bold', 'italic', 'underline', 'clear']],
- ['fontsize', ['fontsize']],
- ['color', ['color']],
- ['para', ['ul', 'ol', 'paragraph']],
- ['table', ['table']],
- ['insert', ['link', 'picture']],
- ['history', ['undo', 'redo']],
- ],
- 'styleWithSpan': false,
- 'inlinemedia' : ['p'],
- 'lang': 'odoo',
- 'onChange': function (html, $editable) {
- $editable.trigger('content_changed');
- },
- 'colors': summernoteCustomColors,
- };
- },
- /**
- * Gets jQuery cloned element with internal text nodes escaped for XML
- * storage.
- *
- * @private
- * @param {jQuery} $el
- * @return {jQuery}
- */
- _getEscapedElement: function ($el) {
- var escaped_el = $el.clone();
- var to_escape = escaped_el.find('*').addBack();
- to_escape = to_escape.not(to_escape.filter('object,iframe,script,style,[data-oe-model][data-oe-model!="ir.ui.view"]').find('*').addBack());
- to_escape.contents().each(function () {
- if (this.nodeType === 3) {
- this.nodeValue = $('<div />').text(this.nodeValue).html();
- }
- });
- return escaped_el;
- },
- /**
- * Saves one (dirty) element of the page.
- *
- * @private
- * @param {jQuery} $el - the element to save
- * @param {Object} context - the context to use for the saving rpc
- * @param {boolean} [withLang=false]
- * false if the lang must be omitted in the context (saving "master"
- * page element)
- */
- _saveElement: function ($el, context, withLang) {
- return this._rpc({
- model: 'ir.ui.view',
- method: 'save',
- args: [
- $el.data('oe-id'),
- this._getEscapedElement($el).prop('outerHTML'),
- $el.data('oe-xpath') || null,
- ],
- context: context,
- }, withLang ? undefined : {
- noContextKeys: 'lang',
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when any editable element is clicked -> Prevents default browser
- * action for the element.
- *
- * @private
- * @param {Event} e
- */
- _onClick: function (e) {
- e.preventDefault();
- },
- /**
- * Called when the mouse is pressed on the document -> activate element
- * edition.
- *
- * @private
- * @param {Event} ev
- */
- _onMousedown: function (ev) {
- var $target = $(ev.target);
- var $editable = $target.closest('.o_editable');
-
- if (!$editable.length || $.summernote.core.dom.isContentEditableFalse($target)) {
- return;
- }
-
- // Removes strange _moz_abspos attribute when it appears. Cannot
- // find another solution which works in all cases. A grabber still
- // appears at the same time which I did not manage to remove.
- // TODO find a complete and better solution
- _.defer(function () {
- $editable.find('[_moz_abspos]').removeAttr('_moz_abspos');
- });
-
- if ($target.is('a')) {
- /**
- * Remove content editable everywhere and add it on the link only so that characters can be added
- * and removed at the start and at the end of it.
- */
- $target.attr('contenteditable', true);
- _.defer(function () {
- $editable.not($target).attr('contenteditable', false);
- $target.focus();
- });
-
- // Once clicked outside, remove contenteditable on link and reactive all
- $(document).on('mousedown.reactivate_contenteditable', function (e) {
- if ($target.is(e.target)) return;
- $target.removeAttr('contenteditable');
- $editable.attr('contenteditable', true);
- $(document).off('mousedown.reactivate_contenteditable');
- });
- }
-
- if (this && this.$last && (!$editable.length || this.$last[0] !== $editable[0])) {
- var $destroy = this.$last;
- history.splitNext();
-
- _.delay(function () {
- var id = $destroy.data('note-id');
- $destroy.destroy().removeData('note-id').removeAttr('data-note-id');
- $('#note-popover-'+id+', #note-handle-'+id+', #note-dialog-'+id+'').remove();
- }, 150); // setTimeout to remove flickering when change to editable zone (re-create an editor)
- this.$last = null;
- }
- if ($editable.length && (!this.$last || this.$last[0] !== $editable[0])) {
- $editable.summernote(this._getConfig($editable));
-
- $editable.data('NoteHistory', history);
- this.$last = $editable;
-
- // firefox & IE fix
- try {
- document.execCommand('enableObjectResizing', false, false);
- document.execCommand('enableInlineTableEditing', false, false);
- document.execCommand('2D-position', false, false);
- } catch (e) { /* */ }
- document.body.addEventListener('resizestart', function (evt) {evt.preventDefault(); return false;});
- document.body.addEventListener('movestart', function (evt) {evt.preventDefault(); return false;});
- document.body.addEventListener('dragstart', function (evt) {evt.preventDefault(); return false;});
-
- if (!range.create()) {
- $editable.focusIn();
- }
-
- if (dom.isImg($target[0])) {
- $target.trigger('mousedown'); // for activate selection on picture
- }
-
- this._enableEditableArea($editable);
- }
- },
- /**
- * Called when the mouse is unpressed on the document.
- *
- * @private
- * @param {Event} ev
- */
- _onMouseup: function (ev) {
- var $target = $(ev.target);
- var $editable = $target.closest('.o_editable');
-
- if (!$editable.length) {
- return;
- }
-
- var self = this;
- _.defer(function () {
- self.historyRecordUndo($target, 'activate', true);
- });
-
- // Browsers select different content from one to another after a
- // triple click (especially: if triple-clicking on a paragraph on
- // Chrome, blank characters of the element following the paragraph are
- // selected too)
- //
- // The triple click behavior is reimplemented for all browsers here
- if (ev.originalEvent.detail === 3) {
- // Select the whole content inside the deepest DOM element that was
- // triple-clicked
- range.create(ev.target, 0, ev.target, ev.target.childNodes.length).select();
- }
- },
-});
-
-return {
- Class: RTEWidget,
- history: history,
-};
-});
-
-odoo.define('web_editor.rte.summernote_custom_colors', function (require) {
-'use strict';
-
-return [
- ['#000000', '#424242', '#636363', '#9C9C94', '#CEC6CE', '#EFEFEF', '#F7F7F7', '#FFFFFF'],
- ['#FF0000', '#FF9C00', '#FFFF00', '#00FF00', '#00FFFF', '#0000FF', '#9C00FF', '#FF00FF'],
- ['#F7C6CE', '#FFE7CE', '#FFEFC6', '#D6EFD6', '#CEDEE7', '#CEE7F7', '#D6D6E7', '#E7D6DE'],
- ['#E79C9C', '#FFC69C', '#FFE79C', '#B5D6A5', '#A5C6CE', '#9CC6EF', '#B5A5D6', '#D6A5BD'],
- ['#E76363', '#F7AD6B', '#FFD663', '#94BD7B', '#73A5AD', '#6BADDE', '#8C7BC6', '#C67BA5'],
- ['#CE0000', '#E79439', '#EFC631', '#6BA54A', '#4A7B8C', '#3984C6', '#634AA5', '#A54A7B'],
- ['#9C0000', '#B56308', '#BD9400', '#397B21', '#104A5A', '#085294', '#311873', '#731842'],
- ['#630000', '#7B3900', '#846300', '#295218', '#083139', '#003163', '#21104A', '#4A1031']
-];
-});
diff --git a/addons/web_editor/static/src/js/editor/rte.summernote.js b/addons/web_editor/static/src/js/editor/rte.summernote.js
deleted file mode 100644
index 62586a53b5c..00000000000
--- a/addons/web_editor/static/src/js/editor/rte.summernote.js
+++ /dev/null
@@ -1,1274 +0,0 @@
-odoo.define('web_editor.rte.summernote', function (require) {
-'use strict';
-
-var ajax = require('web.ajax');
-var Class = require('web.Class');
-var core = require('web.core');
-var ColorpickerDialog = require('web.colorpicker');
-var mixins = require('web.mixins');
-var base = require('web_editor.base');
-var weContext = require('web_editor.context');
-var rte = require('web_editor.rte');
-var weWidgets = require('web_editor.widget');
-
-var QWeb = core.qweb;
-var _t = core._t;
-
-ajax.jsonRpc('/web/dataset/call', 'call', {
- 'model': 'ir.ui.view',
- 'method': 'read_template',
- 'args': ['web_editor.colorpicker', weContext.get()]
-}).done(function (data) {
- QWeb.add_template(data);
-});
-
-// Summernote Lib (neek change to make accessible: method and object)
-var dom = $.summernote.core.dom;
-var range = $.summernote.core.range;
-var eventHandler = $.summernote.eventHandler;
-var renderer = $.summernote.renderer;
-
-var tplButton = renderer.getTemplate().button;
-var tplIconButton = renderer.getTemplate().iconButton;
-var tplDropdown = renderer.getTemplate().dropdown;
-
-function _rgbToHex(cssColor) {
- var rgba = cssColor.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
- if (!rgba) {
- return cssColor;
- }
- if (rgba[4]) {
- return cssColor;
- }
- var hex = ColorpickerDialog.prototype.convertRgbToHex(
- parseInt(rgba[1]),
- parseInt(rgba[2]),
- parseInt(rgba[3])
- );
- if (!hex) {
- return cssColor; // TODO handle error
- }
- return hex.hex.toUpperCase();
-}
-
-// Update and change the popovers content, and add history button
-var fn_createPalette = renderer.createPalette;
-renderer.createPalette = function ($container, options) {
- fn_createPalette.call(this, $container, options);
-
- if (!QWeb.has_template('web_editor.colorpicker')) {
- return;
- }
-
- var $clpicker = $(QWeb.render('web_editor.colorpicker'));
-
- var groups;
- if ($clpicker.is("colorpicker")) {
- groups = _.map($clpicker.find('[data-name="theme"]'), function (el) {
- return $(el).find("button").empty();
- });
- } else {
- groups = [$clpicker.find("button").empty()];
- }
-
- var html = "<h6 class='mt-2'>" + _t("Theme colors") + "</h6>" + _.map(groups, function ($group) {
- var $row = $("<div/>", {"class": "note-color-row mb8"}).append($group);
- var $after_breaks = $row.find(".o_small + :not(.o_small)");
- if ($after_breaks.length === 0) {
- $after_breaks = $row.find(":nth-child(8n+9)");
- }
- $after_breaks.addClass("o_clear");
- return $row[0].outerHTML;
- }).join("") + "<h6 class='mt-2'>" + _t("Common colors") + "</h6>";
- var $palettes = $container.find(".note-color .note-color-palette");
- $palettes.prepend(html);
-
- // Find the custom colors which are used in the page and add them to the color palette
- var colors = [];
- var $editable = window.__EditorMenuBar_$editable || $();
- _.each($editable.find('[style*="color"]'), function (element) {
- if (element.style.color) {
- colors.push(element.style.color);
- }
- if (element.style.backgroundColor) {
- colors.push(element.style.backgroundColor);
- }
- });
-
- var $customColorPalettes = $container.find('.note-color .note-custom-color-palette').append($('<div/>', {class: "note-color-row"}));
- var $customColorRows = $customColorPalettes.find('.note-color-row');
- _.each(_.uniq(colors), function (color) {
- var hexColor = _rgbToHex(color);
- if (_.indexOf(_.flatten(options.colors), hexColor) < 0) {
- // Create button for used custom color for backColor and foreColor both and add them into palette
- $customColorRows.append('<button type="button" class="o_custom_color" data-color="' + color + '" style="background-color:' + color + ';" />');
- }
- });
-
- $palettes.push.apply($palettes, $customColorPalettes);
-
- var $fore = $palettes.filter(":even").find("button:not(.note-color-btn)").addClass("note-color-btn");
- var $bg = $palettes.filter(":odd").find("button:not(.note-color-btn)").addClass("note-color-btn");
- $fore.each(function () {
- var $el = $(this);
- var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'text-' + $el.data('color');
- $el.attr('data-event', 'foreColor').attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : 'bg-' + $el.data('color'));
- });
- $bg.each(function () {
- var $el = $(this);
- var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'bg-' + $el.data('color');
- $el.attr('data-event', 'backColor').attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : className);
- });
-};
-
-var fn_tplPopovers = renderer.tplPopovers;
-renderer.tplPopovers = function (lang, options) {
- var $popover = $(fn_tplPopovers.call(this, lang, options));
-
- var $imagePopover = $popover.find('.note-image-popover');
- var $linkPopover = $popover.find('.note-link-popover');
- var $airPopover = $popover.find('.note-air-popover');
-
- //////////////// image popover
-
- // add center button for images
- $(tplIconButton('fa fa-align-center', {
- title: _t('Center'),
- event: 'floatMe',
- value: 'center'
- })).insertAfter($imagePopover.find('[data-event="floatMe"][data-value="left"]'));
- $imagePopover.find('button[data-event="removeMedia"]').parent().remove();
- $imagePopover.find('button[data-event="floatMe"][data-value="none"]').remove();
-
- // padding button
- var $padding = $('<div class="btn-group"/>');
- $padding.insertBefore($imagePopover.find('.btn-group:first'));
- var dropdown_content = [
- '<li><a class="dropdown-item" data-event="padding" href="#" data-value="">'+_t('None')+'</a></li>',
- '<li><a class="dropdown-item" data-event="padding" href="#" data-value="small">'+_t('Small')+'</a></li>',
- '<li><a class="dropdown-item" data-event="padding" href="#" data-value="medium">'+_t('Medium')+'</a></li>',
- '<li><a class="dropdown-item" data-event="padding" href="#" data-value="large">'+_t('Large')+'</a></li>',
- '<li><a class="dropdown-item" data-event="padding" href="#" data-value="xl">'+_t('Xl')+'</a></li>',
- ];
- $(tplIconButton('fa fa-plus-square-o', {
- title: _t('Padding'),
- dropdown: tplDropdown(dropdown_content)
- })).appendTo($padding);
-
- // circle, boxed... options became toggled
- $imagePopover.find('[data-event="imageShape"]:not([data-value])').remove();
- var $button = $(tplIconButton('fa fa-sun-o', {
- title: _t('Shadow'),
- event: 'imageShape',
- value: 'shadow'
- })).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="rounded-circle"]'));
-
- // add spin for fa
- var $spin = $('<div class="btn-group d-none only_fa"/>').insertAfter($button.parent());
- $(tplIconButton('fa fa-refresh', {
- title: _t('Spin'),
- event: 'imageShape',
- value: 'fa-spin'
- })).appendTo($spin);
-
- // resize for fa
- var $resizefa = $('<div class="btn-group d-none only_fa"/>')
- .insertAfter($imagePopover.find('.btn-group:has([data-event="resize"])'));
- for (var size=1; size<=5; size++) {
- $(tplButton('<span class="note-fontsize-10">'+size+'x</span>', {
- title: size+"x",
- event: 'resizefa',
- value: size+''
- })).appendTo($resizefa);
- }
- var $colorfa = $airPopover.find('.note-color').clone();
- $colorfa.find(".dropdown-menu").css('min-width', '172px');
- $resizefa.after($colorfa);
-
- // show dialog box and delete
- var $imageprop = $('<div class="btn-group"/>');
- $imageprop.appendTo($imagePopover.find('.popover-body'));
- $(tplIconButton('fa fa-file-image-o', {
- title: _t('Edit'),
- event: 'showImageDialog'
- })).appendTo($imageprop);
- $(tplIconButton('fa fa-trash-o', {
- title: _t('Remove'),
- event: 'delete'
- })).appendTo($imageprop);
-
- $(tplIconButton('fa fa-crop', {
- title: _t('Crop Image'),
- event: 'cropImage',
- })).insertAfter($imagePopover.find('[data-event="imageShape"][data-value="img-thumbnail"]'));
-
- $imagePopover.find('.popover-body').append($airPopover.find(".note-history").clone());
-
- $imagePopover.find('[data-event="showImageDialog"]').before($airPopover.find('[data-event="showLinkDialog"]').clone());
-
- var $alt = $('<div class="btn-group"/>');
- $alt.appendTo($imagePopover.find('.popover-body'));
- $alt.append('<button class="btn btn-secondary" data-event="alt"><strong>' + _t('Description') + ': </strong><span class="o_image_alt"/></button>');
-
- //////////////// link popover
-
- $linkPopover.find('.popover-body').append($airPopover.find(".note-history").clone());
-
- $linkPopover.find('button[data-event="showLinkDialog"] i').attr("class", "fa fa-link");
- $linkPopover.find('button[data-event="unlink"]').before($airPopover.find('button[data-event="showImageDialog"]').clone());
-
- //////////////// text/air popover
-
- //// highlight the text format
- $airPopover.find('.note-style .dropdown-toggle').on('mousedown', function () {
- var $format = $airPopover.find('[data-event="formatBlock"]');
- var node = range.create().sc;
- var formats = $format.map(function () { return $(this).data("value"); }).get();
- while (node && (!node.tagName || (!node.tagName || formats.indexOf(node.tagName.toLowerCase()) === -1))) {
- node = node.parentNode;
- }
- $format.removeClass('active');
- $format.filter('[data-value="'+(node ? node.tagName.toLowerCase() : "p")+'"]')
- .addClass("active");
- });
-
- //////////////// tooltip
-
- setTimeout(function () {
- $airPopover.add($linkPopover).add($imagePopover).find("button")
- .tooltip('dispose')
- .tooltip({
- container: 'body',
- trigger: 'hover',
- placement: 'bottom'
- }).on('click', function () {$(this).tooltip('hide');});
- });
-
- return $popover;
-};
-
-var fn_boutton_update = eventHandler.modules.popover.button.update;
-eventHandler.modules.popover.button.update = function ($container, oStyle) {
- // stop animation when edit content
- var previous = $(".note-control-selection").data('target');
- if (previous) {
- var $previous = $(previous);
- $previous.css({"-webkit-animation-play-state": "", "animation-play-state": "", "-webkit-transition": "", "transition": "", "-webkit-animation": "", "animation": ""});
- $previous.find('.o_we_selected_image').addBack('.o_we_selected_image').removeClass('o_we_selected_image');
- }
- // end
-
- fn_boutton_update.call(this, $container, oStyle);
-
- $container.find('.note-color').removeClass('d-none');
-
- if (oStyle.image) {
- $container.find('[data-event]').removeClass("active");
-
- $container.find('a[data-event="padding"][data-value="small"]').toggleClass("active", $(oStyle.image).hasClass("padding-small"));
- $container.find('a[data-event="padding"][data-value="medium"]').toggleClass("active", $(oStyle.image).hasClass("padding-medium"));
- $container.find('a[data-event="padding"][data-value="large"]').toggleClass("active", $(oStyle.image).hasClass("padding-large"));
- $container.find('a[data-event="padding"][data-value="xl"]').toggleClass("active", $(oStyle.image).hasClass("padding-xl"));
- $container.find('a[data-event="padding"][data-value=""]').toggleClass("active", !$container.find('li a.active[data-event="padding"]').length);
-
- $(oStyle.image).addClass('o_we_selected_image');
-
- if (dom.isImgFont(oStyle.image)) {
- $container.find('[data-event="customColor"][data-value="foreColor"]').attr('data-color', $(oStyle.image).css('color'));
- $container.find('[data-event="customColor"][data-value="backColor"]').attr('data-color', $(oStyle.image).css('background-color'));
- $container.find('.note-fore-color-preview > button').css('border-bottom-color', $(oStyle.image).css('color'));
- $container.find('.note-back-color-preview > button').css('border-bottom-color', $(oStyle.image).css('background-color'));
-
- $container.find('.btn-group:not(.only_fa):has(button[data-event="resize"],button[data-value="img-thumbnail"])').addClass('d-none');
- $container.find('.only_fa').removeClass('d-none');
- $container.find('button[data-event="resizefa"][data-value="2"]').toggleClass("active", $(oStyle.image).hasClass("fa-2x"));
- $container.find('button[data-event="resizefa"][data-value="3"]').toggleClass("active", $(oStyle.image).hasClass("fa-3x"));
- $container.find('button[data-event="resizefa"][data-value="4"]').toggleClass("active", $(oStyle.image).hasClass("fa-4x"));
- $container.find('button[data-event="resizefa"][data-value="5"]').toggleClass("active", $(oStyle.image).hasClass("fa-5x"));
- $container.find('button[data-event="resizefa"][data-value="1"]').toggleClass("active", !$container.find('.active[data-event="resizefa"]').length);
-
- $container.find('button[data-event="imageShape"][data-value="fa-spin"]').toggleClass("active", $(oStyle.image).hasClass("fa-spin"));
- $container.find('button[data-event="imageShape"][data-value="shadow"]').toggleClass("active", $(oStyle.image).hasClass("shadow"));
- $container.find('.btn-group:has(button[data-event="imageShape"])').removeClass("d-none");
-
- } else {
- $container.find('.d-none:not(.only_fa, .note-recent-color)').removeClass('d-none');
- $container.find('.only_fa').addClass('d-none');
- var width = ($(oStyle.image).attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+%)/);
- if (width) {
- width = width[2];
- }
- $container.find('button[data-event="resize"][data-value="auto"]').toggleClass("active", width !== "100%" && width !== "50%" && width !== "25%");
- $container.find('button[data-event="resize"][data-value="1"]').toggleClass("active", width === "100%");
- $container.find('button[data-event="resize"][data-value="0.5"]').toggleClass("active", width === "50%");
- $container.find('button[data-event="resize"][data-value="0.25"]').toggleClass("active", width === "25%");
-
- $container.find('button[data-event="imageShape"][data-value="shadow"]').toggleClass("active", $(oStyle.image).hasClass("shadow"));
-
- if (!$(oStyle.image).is("img")) {
- $container.find('.btn-group:has(button[data-event="imageShape"])').addClass('d-none');
- }
-
- $container.find('.note-color').addClass('d-none');
- }
-
- $container.find('button[data-event="floatMe"][data-value="left"]').toggleClass("active", $(oStyle.image).hasClass("float-left"));
- $container.find('button[data-event="floatMe"][data-value="center"]').toggleClass("active", $(oStyle.image).hasClass("d-block mx-auto"));
- $container.find('button[data-event="floatMe"][data-value="right"]').toggleClass("active", $(oStyle.image).hasClass("float-right"));
-
- $(oStyle.image).trigger('attributes_change');
- } else {
- $container.find('[data-event="customColor"][data-value="foreColor"]').attr('data-color', oStyle.color);
- $container.find('[data-event="customColor"][data-value="backColor"]').attr('data-color', oStyle['background-color']);
- $container.find('.note-fore-color-preview > button').css('border-bottom-color', oStyle.color);
- $container.find('.note-back-color-preview > button').css('border-bottom-color', oStyle['background-color']);
- }
-};
-
-var fn_popover_update = eventHandler.modules.popover.update;
-eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) {
- var $imagePopover = $popover.find('.note-image-popover');
- var $linkPopover = $popover.find('.note-link-popover');
- var $airPopover = $popover.find('.note-air-popover');
-
- fn_popover_update.call(this, $popover, oStyle, isAirMode);
-
- if (oStyle.image) {
- if (oStyle.image.parentNode.className.match(/(^|\s)media_iframe_video(\s|$)/i)) {
- oStyle.image = oStyle.image.parentNode;
- }
- var alt = $(oStyle.image).attr("alt");
-
- $imagePopover.find('.o_image_alt').text( (alt || "").replace(/&quot;/g, '"') ).parent().toggle(oStyle.image.tagName === "IMG");
- $imagePopover.show();
-
- // for video tag (non-void) we select the range over the tag,
- // for other media types we get the first descendant leaf element
- var target_node = oStyle.image;
- if (!oStyle.image.className.match(/(^|\s)media_iframe_video(\s|$)/i)) {
- target_node = dom.firstChild(target_node);
- }
- range.createFromNode(target_node).select();
- // save range on the editor so it is not lost if restored
- eventHandler.modules.editor.saveRange(dom.makeLayoutInfo(target_node).editable());
- } else {
- $(".note-control-selection").hide();
- }
-
- if (oStyle.image || (oStyle.range && (!oStyle.range.isCollapsed() || (oStyle.range.sc.tagName && !dom.isAnchor(oStyle.range.sc)))) || (oStyle.image && !$(oStyle.image).closest('a').length)) {
- $linkPopover.hide();
- oStyle.anchor = false;
- }
-
- if (oStyle.image || oStyle.anchor || (oStyle.range && !$(oStyle.range.sc).closest('.note-editable').length)) {
- $airPopover.hide();
- } else {
- $airPopover.show();
- }
-};
-
-var fn_handle_update = eventHandler.modules.handle.update;
-eventHandler.modules.handle.update = function ($handle, oStyle, isAirMode) {
- fn_handle_update.call(this, $handle, oStyle, isAirMode);
- if (oStyle.image) {
- $handle.find('.note-control-selection').hide();
- }
-};
-
-// Hack for image and link editor
-function getImgTarget($editable) {
- var $handle = $editable ? dom.makeLayoutInfo($editable).handle() : undefined;
- return $(".note-control-selection", $handle).data('target');
-}
-eventHandler.modules.editor.padding = function ($editable, sValue) {
- var $target = $(getImgTarget($editable));
- var paddings = "small medium large xl".split(/\s+/);
- $editable.data('NoteHistory').recordUndo();
- if (sValue.length) {
- paddings.splice(paddings.indexOf(sValue),1);
- $target.toggleClass('padding-'+sValue);
- }
- $target.removeClass("padding-" + paddings.join(" padding-"));
-};
-eventHandler.modules.editor.resize = function ($editable, sValue) {
- var $target = $(getImgTarget($editable));
- $editable.data('NoteHistory').recordUndo();
- var width = ($target.attr('style') || '').match(/(^|;|\s)width:\s*([0-9]+)%/);
- if (width) {
- width = width[2]/100;
- }
- $target.css('width', (width !== sValue && sValue !== "auto") ? (sValue * 100) + '%' : '');
-};
-eventHandler.modules.editor.resizefa = function ($editable, sValue) {
- var $target = $(getImgTarget($editable));
- $editable.data('NoteHistory').recordUndo();
- $target.attr('class', $target.attr('class').replace(/\s*fa-[0-9]+x/g, ''));
- if (+sValue > 1) {
- $target.addClass('fa-'+sValue+'x');
- }
-};
-eventHandler.modules.editor.floatMe = function ($editable, sValue) {
- var $target = $(getImgTarget($editable));
- $editable.data('NoteHistory').recordUndo();
- switch (sValue) {
- case 'center': $target.toggleClass('d-block mx-auto').removeClass('float-right float-left'); break;
- case 'left': $target.toggleClass('float-left').removeClass('float-right d-block mx-auto'); break;
- case 'right': $target.toggleClass('float-right').removeClass('float-left d-block mx-auto'); break;
- }
-};
-eventHandler.modules.editor.imageShape = function ($editable, sValue) {
- var $target = $(getImgTarget($editable));
- $editable.data('NoteHistory').recordUndo();
- $target.toggleClass(sValue);
-};
-
-eventHandler.modules.linkDialog.showLinkDialog = function ($editable, $dialog, linkInfo) {
- $editable.data('range').select();
- $editable.data('NoteHistory').recordUndo();
-
- var def = new $.Deferred();
- core.bus.trigger('link_dialog_demand', {
- $editable: $editable,
- linkInfo: linkInfo,
- onSave: function (linkInfo) {
- linkInfo.range.select();
- $editable.data('range', linkInfo.range);
- def.resolve(linkInfo);
- $editable.trigger('keyup');
- $('.note-popover .note-link-popover').show();
- },
- onCancel: def.reject.bind(def),
- });
- return def;
-};
-eventHandler.modules.imageDialog.showImageDialog = function ($editable) {
- var r = $editable.data('range');
- if (r.sc.tagName && r.sc.childNodes.length) {
- r.sc = r.sc.childNodes[r.so];
- }
- var media = $(r.sc).parents().addBack().filter(function (i, el) {
- return dom.isImg(el);
- })[0];
- core.bus.trigger('media_dialog_demand', {
- $editable: $editable,
- media: media,
- options: {
- lastFilters: ['background'],
- onUpload: $editable.data('callbacks').onUpload,
- noVideos: $editable.data('oe-model') === "mail.compose.message",
- },
- });
- return new $.Deferred().reject();
-};
-$.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) {
- var $editable = layoutInfo.editable();
- var $selection = layoutInfo.handle().find('.note-control-selection');
- core.bus.trigger('alt_dialog_demand', {
- $editable: $editable,
- media: $selection.data('target'),
- });
-};
-$.summernote.pluginEvents.customColor = function (event, editor, layoutInfo, customColor) {
- var defaultColor = event.target.dataset.color;
- core.bus.trigger('color_picker_dialog_demand', {
- color: defaultColor === 'rgba(0, 0, 0, 0)' ? 'rgb(255, 0, 0)' : defaultColor,
- onSave: function (color) {
- var $palettes = $(event.currentTarget).find('.note-custom-color-palette > .note-color-row')
- .append(('<button type="button" class="note-color-btn" data-value="' + color + '" style="background-color:' + color + ';" />'));
- $palettes.filter(':odd').find('button:not([data-event])').attr('data-event', 'backColor');
- $palettes.filter(':even').find('button:not([data-event])').attr('data-event', 'foreColor');
- if (customColor === 'foreColor') {
- $(event.currentTarget).find('.note-fore-color-preview > button').css('border-bottom-color', color);
- $.summernote.pluginEvents.foreColor(event, editor, layoutInfo, color);
- } else {
- $(event.currentTarget).find('.note-back-color-preview > button').css('border-bottom-color', color);
- $.summernote.pluginEvents.backColor(event, editor, layoutInfo, color);
- }
- }
- });
-};
-$.summernote.pluginEvents.cropImage = function (event, editor, layoutInfo, sorted) {
- var $editable = layoutInfo.editable();
- var $selection = layoutInfo.handle().find('.note-control-selection');
- core.bus.trigger('crop_image_dialog_demand', {
- $editable: $editable,
- media: $selection.data('target'),
- });
-};
-
-// Utils
-var fn_is_void = dom.isVoid || function () {};
-dom.isVoid = function (node) {
- return fn_is_void(node) || dom.isImgFont(node) || (node && node.className && node.className.match(/(^|\s)media_iframe_video(\s|$)/i));
-};
-var fn_is_img = dom.isImg || function () {};
-dom.isImg = function (node) {
- return fn_is_img(node) || dom.isImgFont(node) || (node && (node.nodeName === "IMG" || (node.className && node.className.match(/(^|\s)(media_iframe_video|o_image)(\s|$)/i)) ));
-};
-var fn_is_forbidden_node = dom.isForbiddenNode || function () {};
-dom.isForbiddenNode = function (node) {
- if (node.tagName === "BR") {
- return false;
- }
- return fn_is_forbidden_node(node) || $(node).is(".media_iframe_video");
-};
-var fn_is_img_font = dom.isImgFont || function () {};
-dom.isImgFont = function (node) {
- if (fn_is_img_font(node)) return true;
-
- var nodeName = node && node.nodeName.toUpperCase();
- var className = (node && node.className || "");
- if (node && (nodeName === "SPAN" || nodeName === "I") && className.length) {
- var classNames = className.split(/\s+/);
- for (var k=0; k<base.fontIcons.length; k++) {
- if (_.intersection(base.fontIcons[k].alias, classNames).length) {
- return true;
- }
- }
- }
- return false;
-};
-var fn_is_font = dom.isFont; // re-overwrite font to include theme icons
-dom.isFont = function (node) {
- return fn_is_font(node) || dom.isImgFont(node);
-};
-
-var fn_visible = $.summernote.pluginEvents.visible;
-$.summernote.pluginEvents.visible = function (event, editor, layoutInfo) {
- var res = fn_visible.apply(this, arguments);
- var rng = range.create();
- if (!rng) return res;
- var $node = $(dom.node(rng.sc));
- if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) &&
- $node.hasClass("o_editable") &&
- !$node[0].children.length &&
- "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span label".toUpperCase().indexOf($node[0].nodeName) === -1) {
- var p = $('<p><br/></p>')[0];
- $node.append( p );
- range.createFromNode(p.firstChild).select();
- }
- return res;
-};
-
-function prettify_html(html) {
- html = html.trim();
- var result = '',
- level = 0,
- get_space = function (level) {
- var i = level, space = '';
- while (i--) space += ' ';
- return space;
- },
- reg = /^<\/?(a|span|font|u|em|i|strong|b)(\s|>)/i,
- inline_level = Infinity,
- tokens = _.compact(_.flatten(_.map(html.split(/</), function (value) {
- value = value.replace(/\s+/g, ' ').split(/>/);
- value[0] = /\S/.test(value[0]) ? '<' + value[0] + '>' : '';
- return value;
- })));
-
- // reduce => merge inline style + text
-
- for (var i = 0, l = tokens.length; i < l; i++) {
- var token = tokens[i];
- var inline_tag = reg.test(token);
- var inline = inline_tag || inline_level <= level;
-
- if (token[0] === '<' && token[1] === '/') {
- if (inline_tag && inline_level === level) {
- inline_level = Infinity;
- }
- level--;
- }
-
- if (!inline && !/\S/.test(token)) {
- continue;
- }
- if (!inline || (token[1] !== '/' && inline_level > level)) {
- result += get_space(level);
- }
-
- if (token[0] === '<' && token[1] !== '/') {
- level++;
- if (inline_tag && inline_level > level) {
- inline_level = level;
- }
- }
-
- if (token.match(/^<(img|hr|br)/)) {
- level--;
- }
-
- // don't trim inline content (which could change appearance)
- if (!inline) {
- token = token.trim();
- }
-
- result += token.replace(/\s+/, ' ');
-
- if (inline_level > level) {
- result += '\n';
- }
- }
- return result;
-}
-
-/*
- * This override when clicking on the 'Code View' button has two aims:
- *
- * - have our own code view implementation for FieldTextHtml
- * - add an 'enable' paramater to call the function directly and allow us to
- * disable (false) or enable (true) the code view mode.
- */
-$.summernote.pluginEvents.codeview = function (event, editor, layoutInfo, enable) {
- if (layoutInfo === undefined) {
- return;
- }
- if (layoutInfo.toolbar) {
- // if editor inline (FieldTextHtmlSimple)
- var is_activated = $.summernote.eventHandler.modules.codeview.isActivated(layoutInfo);
- if (is_activated === enable) {
- return;
- }
- return eventHandler.modules.codeview.toggle(layoutInfo);
- } else {
- // if editor iframe (FieldTextHtml)
- var $editor = layoutInfo.editor();
- var $textarea = $editor.prev('textarea');
- if ($textarea.is('textarea') === enable) {
- return;
- }
-
- if (!$textarea.length) {
- // init and create texarea
- var html = prettify_html($editor.prop("innerHTML"));
- $editor.parent().css({
- 'position': 'absolute',
- 'top': 0,
- 'bottom': 0,
- 'left': 0,
- 'right': 0
- });
- $textarea = $('<textarea/>').css({
- 'margin': '0 -4px',
- 'padding': '0 4px',
- 'border': 0,
- 'top': '51px',
- 'left': '620px',
- 'width': '100%',
- 'font-family': 'sans-serif',
- 'font-size': '13px',
- 'height': '98%',
- 'white-space': 'pre',
- 'word-wrap': 'normal'
- }).val(html).data('init', html);
- $editor.before($textarea);
- $editor.hide();
- } else {
- // save changes
- $editor.prop('innerHTML', $textarea.val().replace(/\s*\n\s*/g, '')).trigger('content_changed');
- $textarea.remove();
- $editor.show();
- }
- }
-};
-
-// Fix ie and re-range to don't break snippet
-var last_div;
-var last_div_change;
-var last_editable;
-var initial_data = {};
-function reRangeSelectKey(event) {
- initial_data.range = null;
- if (event.shiftKey && event.keyCode >= 37 && event.keyCode <= 40 && !$(event.target).is("input, textarea, select")) {
- var r = range.create();
- if (r) {
- var rng = r.reRange(event.keyCode <= 38);
- if (r !== rng) {
- rng.select();
- }
- }
- }
-}
-function reRangeSelect(event, dx, dy) {
- var r = range.create();
- if (!r || r.isCollapsed()) return;
-
- // check if the user move the caret on up or down
- var data = r.reRange(dy < 0 || (dy === 0 && dx < 0));
-
- if (data.sc !== r.sc || data.so !== r.so || data.ec !== r.ec || data.eo !== r.eo) {
- setTimeout(function () {
- data.select();
- $(data.sc.parentNode).closest('.note-popover');
- },0);
- }
-
- $(data.sc).closest('.o_editable').data('range', r);
- return r;
-}
-function summernote_mouseup(event) {
- if ($(event.target).closest("#web_editor-top-navbar, .note-popover").length) {
- return;
- }
- // don't rerange if simple click
- if (initial_data.event) {
- var dx = event.clientX - (event.shiftKey && initial_data.rect ? initial_data.rect.left : initial_data.event.clientX);
- var dy = event.clientY - (event.shiftKey && initial_data.rect ? initial_data.rect.top : initial_data.event.clientY);
- if (10 < Math.pow(dx, 2)+Math.pow(dy, 2)) {
- reRangeSelect(event, dx, dy);
- }
- }
-
- if (!$(event.target).closest(".o_editable").length) {
- return;
- }
- if (!initial_data.range || !event.shiftKey) {
- setTimeout(function () {
- initial_data.range = range.create();
- },0);
- }
-}
-var remember_selection;
-function summernote_mousedown(event) {
- rte.history.splitNext();
-
- var $editable = $(event.target).closest(".o_editable, .note-editor");
- var r;
-
- if (document.documentMode) {
- summernote_ie_fix(event, function (node) { return node.tagName === "DIV" || node.tagName === "IMG" || (node.dataset && node.dataset.oeModel); });
- } else if (last_div && event.target !== last_div) {
- if (last_div.tagName === "A") {
- summernote_ie_fix(event, function (node) { return node.dataset && node.dataset.oeModel; });
- } else if ($editable.length) {
- if (summernote_ie_fix(event, function (node) { return node.tagName === "A"; })) {
- r = range.create();
- r.select();
- }
- }
- }
-
- // restore range if range lost after clicking on non-editable area
- try {
- r = range.create();
- } catch (e) {
- // If this code is running inside an iframe-editor and that the range
- // is outside of this iframe, this will fail as the iframe does not have
- // the permission to check the outside content this way. In that case,
- // we simply ignore the exception as it is as if there was no range.
- return;
- }
- var editables = $(".o_editable[contenteditable], .note-editable[contenteditable]");
- var r_editable = editables.has((r||{}).sc).addBack(editables.filter((r||{}).sc));
- if (!r_editable.closest('.note-editor').is($editable) && !r_editable.filter('.o_editable').is(editables)) {
- var saved_editable = editables.has((remember_selection||{}).sc);
- if ($editable.length && !saved_editable.closest('.o_editable, .note-editor').is($editable)) {
- remember_selection = range.create(dom.firstChild($editable[0]), 0);
- } else if (!saved_editable.length) {
- remember_selection = undefined;
- }
- if (remember_selection) {
- try {
- remember_selection.select();
- } catch (e) {
- console.warn(e);
- }
- }
- } else if (r_editable.length) {
- remember_selection = r;
- }
-
- initial_data.event = event;
-
- // keep selection when click with shift
- if (event.shiftKey && $editable.length) {
- if (initial_data.range) {
- initial_data.range.select();
- }
- var rect = r && r.getClientRects();
- initial_data.rect = rect && rect.length ? rect[0] : { top: 0, left: 0 };
- }
-}
-
-function summernote_ie_fix(event, pred) {
- var editable;
- var div;
- var node = event.target;
- while (node.parentNode) {
- if (!div && pred(node)) {
- div = node;
- }
- if (last_div !== node && (node.getAttribute('contentEditable')==='false' || node.className && (node.className.indexOf('o_not_editable') !== -1))) {
- break;
- }
- if (node.className && node.className.indexOf('o_editable') !== -1) {
- if (!div) {
- div = node;
- }
- editable = node;
- break;
- }
- node = node.parentNode;
- }
-
- if (!editable) {
- $(last_div_change).removeAttr("contentEditable").removeProp("contentEditable");
- $(last_editable).attr("contentEditable", "true").prop("contentEditable", "true");
- last_div_change = null;
- last_editable = null;
- return;
- }
-
- if (div === last_div) {
- return;
- }
-
- last_div = div;
-
- $(last_div_change).removeAttr("contentEditable").removeProp("contentEditable");
-
- if (last_editable !== editable) {
- if ($(editable).is("[contentEditable='true']")) {
- $(editable).removeAttr("contentEditable").removeProp("contentEditable");
- last_editable = editable;
- } else {
- last_editable = null;
- }
- }
- if (!$(div).attr("contentEditable") && !$(div).is("[data-oe-type='many2one'], [data-oe-type='contact']")) {
- $(div).attr("contentEditable", "true").prop("contentEditable", "true");
- last_div_change = div;
- } else {
- last_div_change = null;
- }
- return editable !== div ? div : null;
-}
-
-var fn_attach = eventHandler.attach;
-eventHandler.attach = function (oLayoutInfo, options) {
- fn_attach.call(this, oLayoutInfo, options);
-
- oLayoutInfo.editor().on('dragstart', 'img', function (e) { e.preventDefault(); });
- $(document).on('mousedown', summernote_mousedown).on('mouseup', summernote_mouseup);
- oLayoutInfo.editor().off('click').on('click', function (e) {e.preventDefault();}); // if the content editable is a link
-
- /**
- * Open Media Dialog on double click on an image/video/icon.
- * Shows a tooltip on click to say to the user he can double click.
- */
- create_dblclick_feature("img, .media_iframe_video, i.fa, span.fa, a.o_image", function () {
- eventHandler.modules.imageDialog.show(oLayoutInfo);
- });
-
- /**
- * Open Link Dialog on double click on a link/button.
- * Shows a tooltip on click to say to the user he can double click.
- */
- create_dblclick_feature("a[href], a.btn, button.btn", function () {
- eventHandler.modules.linkDialog.show(oLayoutInfo);
- });
-
- oLayoutInfo.editable().on('mousedown', function (e) {
- if (dom.isImg(e.target) && dom.isContentEditable(e.target)) {
- range.createFromNode(e.target).select();
- }
- });
- $(document).on("keyup", reRangeSelectKey);
-
- var clone_data = false;
-
- if (options.model) {
- oLayoutInfo.editable().data({'oe-model': options.model, 'oe-id': options.id});
- }
- if (options.getMediaDomain) {
- oLayoutInfo.editable().data('oe-media-domain', options.getMediaDomain);
- }
-
- var $node = oLayoutInfo.editor();
- if ($node.data('oe-model') || $node.data('oe-translation-id')) {
- $node.on('content_changed', function () {
- var $nodes = $('[data-oe-model], [data-oe-translation-id]')
- .filter(function () { return this !== $node[0];});
-
- if ($node.data('oe-model')) {
- $nodes = $nodes.filter('[data-oe-model="'+$node.data('oe-model')+'"]')
- .filter('[data-oe-id="'+$node.data('oe-id')+'"]')
- .filter('[data-oe-field="'+$node.data('oe-field')+'"]');
- }
- if ($node.data('oe-translation-id')) $nodes = $nodes.filter('[data-oe-translation-id="'+$node.data('oe-translation-id')+'"]');
- if ($node.data('oe-type')) $nodes = $nodes.filter('[data-oe-type="'+$node.data('oe-type')+'"]');
- if ($node.data('oe-expression')) $nodes = $nodes.filter('[data-oe-expression="'+$node.data('oe-expression')+'"]');
- if ($node.data('oe-xpath')) $nodes = $nodes.filter('[data-oe-xpath="'+$node.data('oe-xpath')+'"]');
- if ($node.data('oe-contact-options')) $nodes = $nodes.filter('[data-oe-contact-options="'+$node.data('oe-contact-options')+'"]');
-
- var nodes = $node.get();
-
- if ($node.data('oe-type') === "many2one") {
- $nodes = $nodes.add($('[data-oe-model]')
- .filter(function () { return this !== $node[0] && nodes.indexOf(this) === -1; })
- .filter('[data-oe-many2one-model="'+$node.data('oe-many2one-model')+'"]')
- .filter('[data-oe-many2one-id="'+$node.data('oe-many2one-id')+'"]')
- .filter('[data-oe-type="many2one"]'));
-
- $nodes = $nodes.add($('[data-oe-model]')
- .filter(function () { return this !== $node[0] && nodes.indexOf(this) === -1; })
- .filter('[data-oe-model="'+$node.data('oe-many2one-model')+'"]')
- .filter('[data-oe-id="'+$node.data('oe-many2one-id')+'"]')
- .filter('[data-oe-field="name"]'));
- }
-
- if (!clone_data) {
- clone_data = true;
- $nodes.html(this.innerHTML);
- clone_data = false;
- }
- });
- }
-
- var custom_toolbar = oLayoutInfo.toolbar ? oLayoutInfo.toolbar() : undefined;
- var $toolbar = $(oLayoutInfo.popover()).add(custom_toolbar);
- $('button[data-event="undo"], button[data-event="redo"]', $toolbar).attr('disabled', true);
-
- $(oLayoutInfo.editor())
- .add(oLayoutInfo.handle())
- .add(oLayoutInfo.popover())
- .add(custom_toolbar)
- .on('click content_changed', function () {
- $('button[data-event="undo"]', $toolbar).attr('disabled', !oLayoutInfo.editable().data('NoteHistory').hasUndo());
- $('button[data-event="redo"]', $toolbar).attr('disabled', !oLayoutInfo.editable().data('NoteHistory').hasRedo());
- });
-
- function create_dblclick_feature(selector, callback) {
- var show_tooltip = true;
-
- oLayoutInfo.editor().on("dblclick", selector, function (e) {
- var $target = $(e.target);
- if (!dom.isContentEditable($target)) {
- // Prevent edition of non editable parts
- return;
- }
-
- show_tooltip = false;
- callback();
- e.stopImmediatePropagation();
- });
-
- oLayoutInfo.editor().on("click", selector, function (e) {
- var $target = $(e.target);
- if (!dom.isContentEditable($target)) {
- // Prevent edition of non editable parts
- return;
- }
-
- show_tooltip = true;
- setTimeout(function () {
- if (!show_tooltip) return;
- $target.tooltip({title: _t('Double-click to edit'), trigger: 'manuel', container: 'body'}).tooltip('show');
- setTimeout(function () {
- $target.tooltip('dispose');
- }, 800);
- }, 400);
- });
- }
-};
-var fn_detach = eventHandler.detach;
-eventHandler.detach = function (oLayoutInfo, options) {
- fn_detach.call(this, oLayoutInfo, options);
- oLayoutInfo.editable().off('mousedown');
- oLayoutInfo.editor().off("dragstart");
- oLayoutInfo.editor().off('click');
- $(document).off('mousedown', summernote_mousedown);
- $(document).off('mouseup', summernote_mouseup);
- oLayoutInfo.editor().off("dblclick");
- $(document).off("keyup", reRangeSelectKey);
-};
-
-// Translation for odoo
-$.summernote.lang.odoo = {
- font: {
- bold: _t('Bold'),
- italic: _t('Italic'),
- underline: _t('Underline'),
- strikethrough: _t('Strikethrough'),
- subscript: _t('Subscript'),
- superscript: _t('Superscript'),
- clear: _t('Remove Font Style'),
- height: _t('Line Height'),
- name: _t('Font Family'),
- size: _t('Font Size')
- },
- image: {
- image: _t('File / Image'),
- insert: _t('Insert Image'),
- resizeFull: _t('Resize Full'),
- resizeHalf: _t('Resize Half'),
- resizeQuarter: _t('Resize Quarter'),
- floatLeft: _t('Float Left'),
- floatRight: _t('Float Right'),
- floatNone: _t('Float None'),
- dragImageHere: _t('Drag an image here'),
- selectFromFiles: _t('Select from files'),
- url: _t('Image URL'),
- remove: _t('Remove Image')
- },
- link: {
- link: _t('Link'),
- insert: _t('Insert Link'),
- unlink: _t('Unlink'),
- edit: _t('Edit'),
- textToDisplay: _t('Text to display'),
- url: _t('To what URL should this link go?'),
- openInNewWindow: _t('Open in new window')
- },
- video: {
- video: _t('Video'),
- videoLink: _t('Video Link'),
- insert: _t('Insert Video'),
- url: _t('Video URL?'),
- providers: _t('(YouTube, Vimeo, Vine, Instagram, DailyMotion or Youku)')
- },
- table: {
- table: _t('Table')
- },
- hr: {
- insert: _t('Insert Horizontal Rule')
- },
- style: {
- style: _t('Style'),
- normal: _t('Normal'),
- blockquote: _t('Quote'),
- pre: _t('Code'),
- small: _t('Small'),
- h1: _t('Header 1'),
- h2: _t('Header 2'),
- h3: _t('Header 3'),
- h4: _t('Header 4'),
- h5: _t('Header 5'),
- h6: _t('Header 6')
- },
- lists: {
- unordered: _t('Unordered list'),
- ordered: _t('Ordered list')
- },
- options: {
- help: _t('Help'),
- fullscreen: _t('Full Screen'),
- codeview: _t('Code View')
- },
- paragraph: {
- paragraph: _t('Paragraph'),
- outdent: _t('Outdent'),
- indent: _t('Indent'),
- left: _t('Align left'),
- center: _t('Align center'),
- right: _t('Align right'),
- justify: _t('Justify full')
- },
- color: {
- custom: _t('Custom Color'),
- background: _t('Background Color'),
- foreground: _t('Font Color'),
- transparent: _t('Transparent'),
- setTransparent: _t('None'),
- },
- shortcut: {
- shortcuts: _t('Keyboard shortcuts'),
- close: _t('Close'),
- textFormatting: _t('Text formatting'),
- action: _t('Action'),
- paragraphFormatting: _t('Paragraph formatting'),
- documentStyle: _t('Document Style')
- },
- history: {
- undo: _t('Undo'),
- redo: _t('Redo')
- }
-};
-
-//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
-
-/**
- * @todo get rid of this. This has been implemented as a fix to be able to
- * instantiate media, link and alt dialogs outside the main editor: in the
- * simple HTML fields and forum textarea.
- */
-var SummernoteManager = Class.extend(mixins.EventDispatcherMixin, {
- /**
- * @constructor
- */
- init: function (parent) {
- mixins.EventDispatcherMixin.init.call(this);
- this.setParent(parent);
-
- core.bus.on('alt_dialog_demand', this, this._onAltDialogDemand);
- core.bus.on('color_picker_dialog_demand', this, this._onColorPickerDialogDemand);
- core.bus.on('crop_image_dialog_demand', this, this._onCropImageDialogDemand);
- core.bus.on('link_dialog_demand', this, this._onLinkDialogDemand);
- core.bus.on('media_dialog_demand', this, this._onMediaDialogDemand);
- },
- /**
- * @override
- */
- destroy: function () {
- mixins.EventDispatcherMixin.destroy.call(this);
-
- core.bus.off('alt_dialog_demand', this, this._onAltDialogDemand);
- core.bus.off('color_picker_dialog_demand', this, this._onColorPickerDialogDemand);
- core.bus.off('crop_image_dialog_demand', this, this._onCropImageDialogDemand);
- core.bus.off('link_dialog_demand', this, this._onLinkDialogDemand);
- core.bus.off('media_dialog_demand', this, this._onMediaDialogDemand);
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when a demand to open a alt dialog is received on the bus.
- *
- * @private
- * @param {Object} data
- */
- _onAltDialogDemand: function (data) {
- if (data.__alreadyDone) {
- return;
- }
- data.__alreadyDone = true;
- var altDialog = new weWidgets.AltDialog(this,
- data.options || {},
- data.$editable,
- data.media
- );
- if (data.onSave) {
- altDialog.on('save', this, data.onSave);
- }
- if (data.onCancel) {
- altDialog.on('cancel', this, data.onCancel);
- }
- altDialog.open();
- },
-/**
- * Called when a demand to open a color picker dialog is received on the bus.
- *
- * @private
- * @param {Object} data
- */
- _onColorPickerDialogDemand: function (data) {
- if (data.__alreadyDone) {
- return;
- }
- data.__alreadyDone = true;
- var colorpicker = new ColorpickerDialog(this, {
- defaultColor: data.color,
- });
- if (data.onSave) {
- colorpicker.on('colorpicker:saved', this, function (ev) {
- data.onSave(ev.data.cssColor);
- });
- }
- colorpicker.open();
- },
- /**
- * Called when a demand to open a crop dialog is received on the bus.
- *
- * @private
- * @param {Object} data
- */
- _onCropImageDialogDemand: function (data) {
- if (data.__alreadyDone) {
- return;
- }
- data.__alreadyDone = true;
- var cropImageDialog = new weWidgets.CropImageDialog(this,
- _.extend({
- res_model: data.$editable.data('oe-model'),
- res_id: data.$editable.data('oe-id'),
- }, data.options || {}),
- data.$editable,
- data.media
- );
- if (data.onSave) {
- cropImageDialog.on('save', this, data.onSave);
- }
- if (data.onCancel) {
- cropImageDialog.on('cancel', this, data.onCancel);
- }
- cropImageDialog.open();
- },
- /**
- * Called when a demand to open a link dialog is received on the bus.
- *
- * @private
- * @param {Object} data
- */
- _onLinkDialogDemand: function (data) {
- if (data.__alreadyDone) {
- return;
- }
- data.__alreadyDone = true;
- var linkDialog = new weWidgets.LinkDialog(this,
- data.options || {},
- data.$editable,
- data.linkInfo
- );
- if (data.onSave) {
- linkDialog.on('save', this, data.onSave);
- }
- if (data.onCancel) {
- linkDialog.on('cancel', this, data.onCancel);
- }
- linkDialog.open();
- },
- /**
- * Called when a demand to open a media dialog is received on the bus.
- *
- * @private
- * @param {Object} data
- */
- _onMediaDialogDemand: function (data) {
- if (data.__alreadyDone) {
- return;
- }
- data.__alreadyDone = true;
-
- var mediaDialog = new weWidgets.MediaDialog(this,
- _.extend({
- res_model: data.$editable.data('oe-model'),
- res_id: data.$editable.data('oe-id'),
- domain: data.$editable.data('oe-media-domain'),
- }, data.options),
- data.$editable,
- data.media
- );
- if (data.onSave) {
- mediaDialog.on('save', this, data.onSave);
- }
- if (data.onCancel) {
- mediaDialog.on('cancel', this, data.onCancel);
- }
- mediaDialog.open();
- },
-});
-/**
- * @todo cannot do this without include because it would make a loop in the
- * JS module dependencies otherwise.
- */
-rte.Class.include({
- /**
- * @override
- */
- start: function () {
- this._summernoteManager = new SummernoteManager(this);
- return this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- cancel: function () {
- this._super.apply(this, arguments);
- this._summernoteManager.destroy();
- },
-});
-return SummernoteManager;
-});
diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js
deleted file mode 100644
index 36f5becce06..00000000000
--- a/addons/web_editor/static/src/js/editor/summernote.js
+++ /dev/null
@@ -1,2436 +0,0 @@
-odoo.define('web_editor.summernote', function (require) {
-'use strict';
-
-var core = require('web.core');
-require('summernote/summernote'); // wait that summernote is loaded
-
-var _t = core._t;
-
-// Summernote Lib (neek hack to make accessible: method and object)
-// var agent = $.summernote.core.agent;
-var dom = $.summernote.core.dom;
-var range = $.summernote.core.range;
-var list = $.summernote.core.list;
-var key = $.summernote.core.key;
-var eventHandler = $.summernote.eventHandler;
-var editor = eventHandler.modules.editor;
-var renderer = $.summernote.renderer;
-var options = $.summernote.options;
-
-// Browser-unify execCommand
-var oldJustify = {};
-_.each(['Left', 'Right', 'Full', 'Center'], function (align) {
- oldJustify[align] = editor['justify' + align];
- editor['justify' + align] = function ($editable, value) {
- // Before calling the standard function, check all elements which have
- // an 'align' attribute and mark them with their value
- var $align = $editable.find('[align]');
- _.each($align, function (el) {
- var $el = $(el);
- $el.data('__align', $el.attr('align'));
- });
-
- // Call the standard function
- oldJustify[align].apply(this, arguments);
-
- // Then:
-
- // Remove the text-align of elements which lost the 'align' attribute
- var $newAlign = $editable.find('[align]');
- $align.not($newAlign).css('text-align', '');
-
- // Transform the 'align' attribute into the 'text-align' css
- // property for elements which received the 'align' attribute or whose
- // 'align' attribute changed
- _.each($newAlign, function (el) {
- var $el = $(el);
-
- var oldAlignValue = $align.data('__align');
- var alignValue = $el.attr('align');
- if (oldAlignValue === alignValue) {
- // If the element already had an 'align' attribute and that it
- // did not changed, do nothing (compatibility)
- return;
- }
-
- $el.removeAttr('align');
- $el.css('text-align', alignValue);
-
- // Note the first step (removing the text-align of elemnts which
- // lost the 'align' attribute) is kinda the same as this one, but
- // this one handles the elements which have been edited with chrome
- // or with this new system
- $el.find('*').css('text-align', '');
- });
-
- // Unmark the elements
- $align.removeData('__align');
- };
-});
-
-
-// Add methods to summernote
-
-dom.hasContentAfter = function (node) {
- var next;
- if (dom.isEditable(node)) return;
- while (node.nextSibling) {
- next = node.nextSibling;
- if (next.tagName || dom.isVisibleText(next) || dom.isBR(next)) return next;
- node = next;
- }
-};
-dom.hasContentBefore = function (node) {
- var prev;
- if (dom.isEditable(node)) return;
- while (node.previousSibling) {
- prev = node.previousSibling;
- if (prev.tagName || dom.isVisibleText(prev) || dom.isBR(prev)) return prev;
- node = prev;
- }
-};
-dom.ancestorHaveNextSibling = function (node, pred) {
- pred = pred || dom.hasContentAfter;
- while (!dom.isEditable(node) && (!node.nextSibling || !pred(node))) { node = node.parentNode; }
- return node;
-};
-dom.ancestorHavePreviousSibling = function (node, pred) {
- pred = pred || dom.hasContentBefore;
- while (!dom.isEditable(node) && (!node.previousSibling || !pred(node))) { node = node.parentNode; }
- return node;
-};
-dom.nextElementSibling = function (node) {
- while (node) {
- node = node.nextSibling;
- if (node && node.tagName) {
- break;
- }
- }
- return node;
-};
-dom.previousElementSibling = function (node) {
- while (node) {
- node = node.previousSibling;
- if (node && node.tagName) {
- break;
- }
- }
- return node;
-};
-dom.lastChild = function (node) {
- while (node.lastChild) { node = node.lastChild; }
- return node;
-};
-dom.firstChild = function (node) {
- while (node.firstChild) { node = node.firstChild; }
- return node;
-};
-dom.lastElementChild = function (node, deep) {
- node = deep ? dom.lastChild(node) : node.lastChild;
- return !node || node.tagName ? node : dom.previousElementSibling(node);
-};
-dom.firstElementChild = function (node, deep) {
- node = deep ? dom.firstChild(node) : node.firstChild;
- return !node || node.tagName ? node : dom.nextElementSibling(node);
-};
-dom.isEqual = function (prev, cur) {
- if (prev.tagName !== cur.tagName) {
- return false;
- }
- if ((prev.attributes ? prev.attributes.length : 0) !== (cur.attributes ? cur.attributes.length : 0)) {
- return false;
- }
-
- function strip(text) {
- return text && text.replace(/^\s+|\s+$/g, '').replace(/\s+/g, ' ');
- }
- var att, att2;
- loop_prev:
- for (var a in prev.attributes) {
- att = prev.attributes[a];
- for (var b in cur.attributes) {
- att2 = cur.attributes[b];
- if (att.name === att2.name) {
- if (strip(att.value) !== strip(att2.value)) return false;
- continue loop_prev;
- }
- }
- return false;
- }
- return true;
-};
-dom.hasOnlyStyle = function (node) {
- for (var i = 0; i < node.attributes.length; i++) {
- var attr = node.attributes[i];
- if (attr.attributeName !== 'style') {
- return false;
- }
- }
- return true;
-};
-dom.hasProgrammaticStyle = function (node) {
- var styles = ["float", "display", "position", "top", "left", "right", "bottom"];
- for (var i = 0; i < node.style.length; i++) {
- var style = node.style[i];
- if (styles.indexOf(style) !== -1) {
- return true;
- }
- }
- return false;
-};
-dom.mergeFilter = function (prev, cur, parent) {
- // merge text nodes
- if (prev && (dom.isText(prev) || (['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P'].indexOf(prev.tagName) !== -1 && prev !== cur.parentNode)) && dom.isText(cur)) {
- return true;
- }
- if (prev && prev.tagName === "P" && dom.isText(cur)) {
- return true;
- }
- if (prev && dom.isText(cur) && !dom.isVisibleText(cur) && (dom.isText(prev) || dom.isVisibleText(prev))) {
- return true;
- }
- if (prev && !dom.isBR(prev) && dom.isEqual(prev, cur) &&
- ((prev.tagName && dom.getComputedStyle(prev).display === "inline" &&
- cur.tagName && dom.getComputedStyle(cur).display === "inline"))) {
- return true;
- }
- if (dom.isEqual(parent, cur) &&
- ((parent.tagName && dom.getComputedStyle(parent).display === "inline" &&
- cur.tagName && dom.getComputedStyle(cur).display === "inline"))) {
- return true;
- }
- if (parent && cur.tagName === "FONT" && (!cur.firstChild || (!cur.attributes.getNamedItem('style') && !cur.className.length))) {
- return true;
- }
- // On backspace, webkit browsers create a <span> with a bunch of
- // inline styles "remembering" where they come from.
- // chances are we had e.g.
- // <p>foo</p>
- // <p>bar</p>
- // merged the lines getting this in webkit
- // <p>foo<span>bar</span></p>
- if (parent && cur.tagName === "SPAN" && dom.hasOnlyStyle(cur) && !dom.hasProgrammaticStyle(cur)) {
- return true;
- }
-};
-dom.doMerge = function (prev, cur) {
- if (prev.tagName) {
- if (prev.childNodes.length && !prev.textContent.match(/\S/) && dom.firstElementChild(prev) && dom.isBR(dom.firstElementChild(prev))) {
- prev.removeChild(dom.firstElementChild(prev));
- }
- if (cur.tagName) {
- while (cur.firstChild) {
- prev.appendChild(cur.firstChild);
- }
- cur.parentNode.removeChild(cur);
- } else {
- prev.appendChild(cur);
- }
- } else {
- if (cur.tagName) {
- var deep = cur;
- while (deep.tagName && deep.firstChild) {deep = deep.firstChild;}
- prev.appendData(deep.textContent);
- cur.parentNode.removeChild(cur);
- } else {
- prev.appendData(cur.textContent);
- cur.parentNode.removeChild(cur);
- }
- }
-};
-dom.merge = function (node, begin, so, end, eo, mergeFilter, all) {
- mergeFilter = mergeFilter || dom.mergeFilter;
- var _merged = false;
- var add = all || false;
-
- if (!begin) {
- begin = node;
- while (begin.firstChild) {begin = begin.firstChild;}
- so = 0;
- } else if (begin.tagName && begin.childNodes[so]) {
- begin = begin.childNodes[so];
- so = 0;
- }
- if (!end) {
- end = node;
- while (end.lastChild) {end = end.lastChild;}
- eo = end.textContent.length-1;
- } else if (end.tagName && end.childNodes[so]) {
- end = end.childNodes[so];
- so = 0;
- }
-
- begin = dom.firstChild(begin);
- if (dom.isText(begin) && so > begin.textContent.length) {
- so = 0;
- }
- end = dom.firstChild(end);
- if (dom.isText(end) && eo > end.textContent.length) {
- eo = 0;
- }
-
- function __merge(node) {
- var merged = false;
- var prev;
- for (var k=0; k<node.childNodes.length; k++) {
- var cur = node.childNodes[k];
-
- if (cur === begin) {
- if (!all) add = true;
- }
-
- __merge(cur);
- dom.orderClass(dom.node(cur));
-
- if (!add || !cur) continue;
- if (cur === end) {
- if (!all) add = false;
- }
-
- // create the first prev value
- if (!prev) {
- if (mergeFilter.call(dom, prev, cur, node)) {
- prev = prev || cur.previousSibling;
- dom.moveTo(cur, cur.parentNode, cur);
- k--;
- } else {
- prev = cur;
- }
- continue;
- } else if (mergeFilter.call(dom, null, cur, node)) { // merge with parent
- prev = prev || cur.previousSibling;
- dom.moveTo(cur, cur.parentNode, cur);
- k--;
- continue;
- }
-
- // merge nodes
- if (mergeFilter.call(dom, prev, cur, node)) {
- var p = prev;
- var c = cur;
- // compute prev/end and offset
- if (prev.tagName) {
- if (cur.tagName) {
- if (cur === begin) begin = prev;
- if (cur === end) end = prev;
- }
- } else {
- if (cur.tagName) {
- var deep = cur;
- while (deep.tagName && deep.lastChild) {deep = deep.lastChild;}
- if (deep === begin) {
- so += prev.textContent.length;
- begin = prev;
- }
- if (deep === end) {
- eo += prev.textContent.length;
- end = prev;
- }
- } else {
- // merge text nodes
- if (cur === begin) {
- so += prev.textContent.length;
- begin = prev;
- }
- if (cur === end) {
- eo += prev.textContent.length;
- end = prev;
- }
- }
- }
-
- dom.doMerge(p, c);
-
- merged = true;
- k--;
- continue;
- }
-
- prev = cur;
- }
-
- // an other loop to merge the new shibbing nodes
- if (merged) {
- _merged = true;
- __merge(node);
- }
- }
- if (node) {
- __merge(node);
- }
-
- return {
- merged: _merged,
- sc: begin,
- ec: end,
- so: so,
- eo: eo
- };
-};
-dom.autoMerge = function (target, previous) {
- var node = dom.lastChild(target);
- var nodes = [];
- var temp;
-
- while (node) {
- nodes.push(node);
- temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node));
- if (temp) {
- if (!dom.isText(node) && !dom.isMergable(node) && temp.tagName !== node.tagName) {
- nodes = [];
- }
- break;
- }
- node = node.parentNode;
- }
-
- while (nodes.length) {
- node = nodes.pop();
- if (node && (temp = (previous ? dom.hasContentBefore(node) : dom.hasContentAfter(node))) &&
- temp.tagName === node.tagName &&
- !dom.isText(node) &&
- dom.isMergable(node) &&
- !dom.isNotBreakable(node) && !dom.isNotBreakable(previous ? dom.previousElementSibling(node) : dom.nextElementSibling(node))) {
-
- if (previous) {
- dom.doMerge(temp, node);
- } else {
- dom.doMerge(node, temp);
- }
- }
- }
-};
-dom.removeSpace = function (node, begin, so, end, eo) {
- var removed = false;
- var add = node === begin;
-
- if (node === begin && begin === end && dom.isBR(node)) {
- return {
- removed: removed,
- sc: begin,
- ec: end,
- so: so,
- eo: eo
- };
- }
-
- (function __remove_space(node) {
- if (!node) return;
- var t_begin, t_end;
- for (var k=0; k<node.childNodes.length; k++) {
- var cur = node.childNodes[k];
-
- if (cur === begin) add = true;
-
- if (cur.tagName && cur.tagName !== "SCRIPT" && cur.tagName !== "STYLE" && dom.getComputedStyle(cur).whiteSpace !== "pre") {
- __remove_space(cur);
- }
-
- if (!add) continue;
- if (cur === end) add = false;
-
- // remove begin empty text node
- if (node.childNodes.length > 1 && dom.isText(cur) && !dom.isVisibleText(cur)) {
- removed = true;
- if (cur === begin) {
- t_begin = dom.hasContentBefore(dom.ancestorHavePreviousSibling(cur));
- if (t_begin) {
- so = 0;
- begin = dom.lastChild(t_begin);
- }
- }
- if (cur === end) {
- t_end = dom.hasContentAfter(dom.ancestorHaveNextSibling(cur));
- if (t_end) {
- eo = 1;
- end = dom.firstChild(t_end);
- if (dom.isText(end)) {
- eo = end.textContent.length;
- }
- }
- }
- cur.parentNode.removeChild(cur);
- begin = dom.lastChild(begin);
- end = dom.lastChild(end);
- k--;
- continue;
- }
-
- // convert HTML space
- if (dom.isText(cur)) {
- var text;
- var temp;
- var _temp;
- var exp1 = /[\t\n\r ]+/g;
- var exp2 = /(?!([ ]|\u00A0)|^)\u00A0(?!([ ]|\u00A0)|$)/g;
- if (cur === begin) {
- temp = cur.textContent.substr(0, so);
- _temp = temp.replace(exp1, ' ').replace(exp2, ' ');
- so -= temp.length - _temp.length;
- }
- if (cur === end) {
- temp = cur.textContent.substr(0, eo);
- _temp = temp.replace(exp1, ' ').replace(exp2, ' ');
- eo -= temp.length - _temp.length;
- }
- text = cur.textContent.replace(exp1, ' ').replace(exp2, ' ');
- removed = removed || cur.textContent.length !== text.length;
- cur.textContent = text;
- }
- }
- })(node);
-
- return {
- removed: removed,
- sc: begin,
- ec: end,
- so: !dom.isBR(begin) && so > 0 ? so : 0,
- eo: dom.isBR(end) ? 0 : eo
- };
-};
-dom.removeBetween = function (sc, so, ec, eo, towrite) {
- var text;
- if (ec.tagName) {
- if (ec.childNodes[eo]) {
- ec = ec.childNodes[eo];
- eo = 0;
- } else {
- ec = dom.lastChild(ec);
- eo = dom.nodeLength(ec);
- }
- }
- if (sc.tagName) {
- sc = sc.childNodes[so] || dom.firstChild(ec);
- so = 0;
- if (!dom.hasContentBefore(sc) && towrite) {
- sc.parentNode.insertBefore(document.createTextNode('\u00A0'), sc);
- }
- }
- if (!eo && sc !== ec) {
- ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(ec)) || ec);
- eo = ec.textContent.length;
- }
-
- var ancestor = dom.commonAncestor(sc.tagName ? sc.parentNode : sc, ec.tagName ? ec.parentNode : ec) || dom.ancestor(sc, dom.isEditable);
-
- if (!dom.isContentEditable(ancestor)) {
- return {
- sc: sc,
- so: so,
- ec: sc,
- eo: eo
- };
- }
-
- if (ancestor.tagName) {
- var ancestor_sc = sc;
- var ancestor_ec = ec;
- while (ancestor !== ancestor_sc && ancestor !== ancestor_sc.parentNode) { ancestor_sc = ancestor_sc.parentNode; }
- while (ancestor !== ancestor_ec && ancestor !== ancestor_ec.parentNode) { ancestor_ec = ancestor_ec.parentNode; }
-
-
- var node = dom.node(sc);
- if (!dom.isNotBreakable(node) && !dom.isVoid(sc)) {
- sc = dom.splitTree(ancestor_sc, {'node': sc, 'offset': so});
- }
- var before = dom.hasContentBefore(dom.ancestorHavePreviousSibling(sc));
-
- var after;
- if (ec.textContent.slice(eo, Infinity).match(/\S|\u00A0/)) {
- after = dom.splitTree(ancestor_ec, {'node': ec, 'offset': eo});
- } else {
- after = dom.hasContentAfter(dom.ancestorHaveNextSibling(ec));
- }
-
- var nodes = dom.listBetween(sc, ec);
-
- var ancestor_first_last = function (node) {
- return node === before || node === after;
- };
-
- for (var i=0; i<nodes.length; i++) {
- if (!dom.ancestor(nodes[i], ancestor_first_last) && !$.contains(nodes[i], before) && !$.contains(nodes[i], after) && !dom.isEditable(nodes[i])) {
- nodes[i].parentNode.removeChild(nodes[i]);
- }
- }
-
- if (dom.listAncestor(after).length <= dom.listAncestor(before).length) {
- sc = dom.lastChild(before || ancestor);
- so = dom.nodeLength(sc);
- } else {
- sc = dom.firstChild(after);
- so = 0;
- }
-
- if (dom.isVoid(node)) {
- // we don't need to append a br
- } else if (towrite && !node.firstChild && node.parentNode && !dom.isNotBreakable(node)) {
- var br = $("<br/>")[0];
- node.appendChild(sc);
- sc = br;
- so = 0;
- } else if (!ancestor.children.length && !ancestor.textContent.match(/\S|\u00A0/)) {
- sc = $("<br/>")[0];
- so = 0;
- $(ancestor).prepend(sc);
- } else if (dom.isText(sc)) {
- text = sc.textContent.replace(/[ \t\n\r]+$/, '\u00A0');
- so = Math.min(so, text.length);
- sc.textContent = text;
- }
- } else {
- text = ancestor.textContent;
- ancestor.textContent = text.slice(0, so) + text.slice(eo, Infinity).replace(/^[ \t\n\r]+/, '\u00A0');
- }
-
- eo = so;
- if (!dom.isBR(sc) && !dom.isVisibleText(sc) && !dom.isText(dom.hasContentBefore(sc)) && !dom.isText(dom.hasContentAfter(sc))) {
- ancestor = dom.node(sc);
- text = document.createTextNode('\u00A0');
- $(sc).before(text);
- sc = text;
- so = 0;
- eo = 1;
- }
-
- var parentNode = sc && sc.parentNode;
- if (parentNode && sc.tagName === 'BR') {
- sc = parentNode;
- ec = parentNode;
- }
-
- return {
- sc: sc,
- so: so,
- ec: sc,
- eo: eo
- };
-};
-dom.indent = function (node) {
- var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft';
- var margin = parseFloat(node.style[style] || 0)+1.5;
- node.style[style] = margin + "em";
- return margin;
-};
-dom.outdent = function (node) {
- var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft';
- var margin = parseFloat(node.style[style] || 0)-1.5;
- node.style[style] = margin > 0 ? margin + "em" : "";
- return margin;
-};
-dom.scrollIntoViewIfNeeded = function (node) {
- node = dom.node(node);
-
- var $span;
- if (dom.isBR(node)) {
- $span = $('<span/>').text('\u00A0');
- $(node).after($span);
- node = $span[0];
- }
-
- if (node.scrollIntoViewIfNeeded) {
- node.scrollIntoViewIfNeeded(false);
- } else {
- var offsetParent = node.offsetParent;
- while (offsetParent) {
- var elY = 0;
- var elH = node.offsetHeight;
- var parent = node;
-
- while (offsetParent && parent) {
- elY += node.offsetTop;
-
- // get if a parent have a scrollbar
- parent = node.parentNode;
- while (parent !== offsetParent &&
- (parent.tagName === "BODY" || ["auto", "scroll"].indexOf(dom.getComputedStyle(parent).overflowY) === -1)) {
- parent = parent.parentNode;
- }
- node = parent;
-
- if (parent !== offsetParent) {
- elY -= parent.offsetTop;
- parent = null;
- }
-
- offsetParent = node.offsetParent;
- }
-
- if ((node.tagName === "BODY" || ["auto", "scroll"].indexOf(dom.getComputedStyle(node).overflowY) !== -1) &&
- (node.scrollTop + node.clientHeight) < (elY + elH)) {
- node.scrollTop = (elY + elH) - node.clientHeight;
- }
- }
- }
-
- if ($span) {
- $span.remove();
- }
-
- return;
-};
-dom.moveTo = function (node, target, before) {
- var nodes = [];
- while (node.firstChild) {
- nodes.push(node.firstChild);
- if (before) {
- target.insertBefore(node.firstChild, before);
- } else {
- target.appendChild(node.firstChild);
- }
- }
- node.parentNode.removeChild(node);
- return nodes;
-};
-dom.isMergable = function (node) {
- return node.tagName && "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a ul ol font".indexOf(node.tagName.toLowerCase()) !== -1;
-};
-dom.isSplitable = function (node) {
- return node.tagName && "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a font".indexOf(node.tagName.toLowerCase()) !== -1;
-};
-dom.isRemovableEmptyNode = function (node) {
- return "h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small li a ul ol font span br".indexOf(node.tagName.toLowerCase()) !== -1;
-};
-dom.isForbiddenNode = function (node) {
- return node.tagName === "BR" || $(node).is(".fa, img");
-};
-/**
- * @todo 'so' and 'eo' were added as a bugfix and are not given everytime. They
- * however should be as the function may be wrong without them (for example,
- * when asking the list between an element and its parent, as there is no path
- * from the beginning of the former to the beginning of the later).
- */
-dom.listBetween = function (sc, ec, so, eo) {
- var nodes = [];
- var ancestor = dom.commonAncestor(sc, ec);
- dom.walkPoint({'node': sc, 'offset': so || 0}, {'node': ec, 'offset': eo || 0}, function (point) {
- if (ancestor !== point.node || ancestor === sc || ancestor === ec) {
- nodes.push(point.node);
- }
- });
- return list.unique(nodes);
-};
-dom.isNotBreakable = function (node) {
- // avoid triple click => crappy dom
- return !dom.isText(node) && !dom.isBR(dom.firstChild(node)) && dom.isVoid(dom.firstChild(node));
-};
-dom.isContentEditable = function (node) {
- return $(node).closest('[contenteditable]').prop('contenteditable') === 'true';
-};
-dom.isContentEditableFalse = function (node) {
- return $(node).closest('[contenteditable]').prop('contenteditable') === 'false';
-};
-dom.isFont = function (node) {
- var nodeName = node && node.nodeName.toUpperCase();
- return node && (nodeName === "FONT" ||
- (nodeName === "SPAN" && (
- node.className.match(/(^|\s)fa(\s|$)/i) ||
- node.className.match(/(^|\s)(text|bg)-/i) ||
- (node.attributes.style && node.attributes.style.value.match(/(^|\s)(color|background-color|font-size):/i)))) );
-};
-dom.isVisibleText = function (textNode) {
- return !!textNode.textContent.match(/\S|\u00A0/);
-};
-var old_isVisiblePoint = dom.isVisiblePoint;
-dom.isVisiblePoint = function (point) {
- return point.node.nodeType !== 8 && old_isVisiblePoint.apply(this, arguments);
-};
-dom.orderStyle = function (node) {
- var style = node.getAttribute('style');
- if (!style) return null;
- style = style.replace(/[\s\n\r]+/, ' ').replace(/^ ?;? ?| ?;? ?$/g, '').replace(/ ?; ?/g, ';');
- if (!style.length) {
- node.removeAttribute("style");
- return null;
- }
- style = style.split(";");
- style.sort();
- style = style.join("; ")+";";
- node.setAttribute('style', style);
- return style;
-};
-dom.orderClass = function (node) {
- var className = node.getAttribute && node.getAttribute('class');
- if (!className) return null;
- className = className.replace(/[\s\n\r]+/, ' ').replace(/^ | $/g, '').replace(/ +/g, ' ');
- if (!className.length) {
- node.removeAttribute("class");
- return null;
- }
- className = className.split(" ");
- className.sort();
- className = className.join(" ");
- node.setAttribute('class', className);
- return className;
-};
-dom.node = function (node) {
- return dom.isText(node) ? node.parentNode : node;
-};
-dom.moveContent = function (from, to) {
- if (from === to) {
- return;
- }
- if (from.parentNode === to) {
- while (from.lastChild) {
- dom.insertAfter(from.lastChild, from);
- }
- } else {
- while (from.firstChild && from.firstChild !== to) {
- to.appendChild(from.firstChild);
- }
- }
-};
-dom.getComputedStyle = function (node) {
- return node.nodeType === Node.COMMENT_NODE ? {} : window.getComputedStyle(node);
-};
-
-//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
-
-range.WrappedRange.prototype.reRange = function (keep_end, isNotBreakable) {
- var sc = this.sc;
- var so = this.so;
- var ec = this.ec;
- var eo = this.eo;
- isNotBreakable = isNotBreakable || dom.isNotBreakable;
-
- // search the first snippet editable node
- var start = keep_end ? ec : sc;
- while (start) {
- if (isNotBreakable(start, sc, so, ec, eo)) {
- break;
- }
- start = start.parentNode;
- }
-
- // check if the end caret have the same node
- var lastFilterEnd;
- var end = keep_end ? sc : ec;
- while (end) {
- if (start === end) {
- break;
- }
- if (isNotBreakable(end, sc, so, ec, eo)) {
- lastFilterEnd = end;
- }
- end = end.parentNode;
- }
- if (lastFilterEnd) {
- end = lastFilterEnd;
- }
- if (!end) {
- end = document.getElementsByTagName('body')[0];
- }
-
- // if same node, keep range
- if (start === end || !start) {
- return this;
- }
-
- // reduce or extend the range to don't break a isNotBreakable area
- if ($.contains(start, end)) {
-
- if (keep_end) {
- sc = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)) || sc);
- so = sc.textContent.length;
- } else if (!eo) {
- ec = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(end)) || ec);
- eo = ec.textContent.length;
- } else {
- ec = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(end)) || ec);
- eo = 0;
- }
- } else {
-
- if (keep_end) {
- sc = dom.firstChild(start);
- so = 0;
- } else {
- ec = dom.lastChild(start);
- eo = ec.textContent.length;
- }
- }
-
- return new range.WrappedRange(sc, so, ec, eo);
-};
-/**
- * Returns the image the range is in or matches (if any, false otherwise).
- *
- * @todo this implementation may not cover all corner cases but should do the
- * trick for all reproductible ones
- * @returns {DOMElement|boolean}
- */
-range.WrappedRange.prototype.isOnImg = function () {
- // If not a selection but a cursor position, just check if a point's
- // ancestor is an image or not
- if (this.sc === this.ec && this.so === this.eo) {
- return dom.ancestor(this.sc, dom.isImg);
- }
-
- var startPoint = {node: this.sc, offset: this.so};
- var endPoint = {node: this.ec, offset: this.eo};
-
- var nb = 0;
- var image;
- var textNode;
- dom.walkPoint(startPoint, endPoint, function (point) {
- // If the element has children (not a text node and not empty node),
- // the element cannot be considered as selected (these children will
- // be processed to determine that)
- if (dom.hasChildren(point.node)) {
- return;
- }
-
- // Check if an ancestor of the current point is an image
- var pointImg = dom.ancestor(point.node, dom.isImg);
- var isText = dom.isText(point.node);
-
- // Check if a visible element is selected, i.e.
- // - If an ancestor of the current is an image we did not see yet
- // - If the point is not in a br or a text (so a node with no children)
- // - If the point is in a non empty text node we already saw
- if (pointImg ?
- (image !== pointImg) :
- ((!dom.isBR(point.node) && !isText) || (textNode === point.node && point.node.textContent.match(/\S|\u00A0/)))) {
- nb++;
- }
-
- // If an ancestor of the current point is an image, then save it as the
- // image we are looking for
- if (pointImg) {
- image = pointImg;
- }
- // If the current point is a text node save it as the last text node
- // seen (if we see it again, this might mean it is selected)
- if (isText) {
- textNode = point.node;
- }
- });
-
- return nb === 1 && image;
-};
-range.WrappedRange.prototype.deleteContents = function (towrite) {
- if (this.sc === this.ec && this.so === this.eo) {
- return this;
- }
-
- var r;
- var image = this.isOnImg();
- if (image) {
- // If the range matches/is in an image, then the image is to be removed
- // and the cursor moved to its previous position
- var parentNode = image.parentNode;
- var index = _.indexOf(parentNode.childNodes, image);
- parentNode.removeChild(image);
- r = new range.WrappedRange(parentNode, index, parentNode, index);
- } else {
- r = dom.removeBetween(this.sc, this.so, this.ec, this.eo, towrite);
- }
-
- $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
- return new range.WrappedRange(r.sc, r.so, r.ec, r.eo);
-};
-range.WrappedRange.prototype.clean = function (mergeFilter, all) {
- var node = dom.node(this.sc === this.ec ? this.sc : this.commonAncestor());
- node = node || $(this.sc).closest('[contenteditable]')[0];
- if (node.childNodes.length <=1) {
- return this;
- }
-
- var merge = dom.merge(node, this.sc, this.so, this.ec, this.eo, mergeFilter, all);
- var rem = dom.removeSpace(node.parentNode, merge.sc, merge.so, merge.ec, merge.eo);
-
- if (merge.merged || rem.removed) {
- return range.create(rem.sc, rem.so, rem.ec, rem.eo);
- }
- return this;
-};
-range.WrappedRange.prototype.remove = function (mergeFilter) {
-};
-range.WrappedRange.prototype.isOnCellFirst = function () {
- var node = dom.ancestor(this.sc, function (node) {return ["LI", "DIV", "TD","TH"].indexOf(node.tagName) !== -1;});
- return node && ["TD","TH"].indexOf(node.tagName) !== -1;
-};
-range.WrappedRange.prototype.isContentEditable = function () {
- return dom.isContentEditable(this.sc) && (this.sc === this.ec || dom.isContentEditable(this.ec));
-};
-
-//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
-
-renderer.tplButtonInfo.fontsize = function (lang, options) {
- var items = options.fontSizes.reduce(function (memo, v) {
- return memo + '<a data-event="fontSize" href="#" class="dropdown-item" data-value="' + v + '">' +
- '<i class="fa fa-check"></i> ' + v +
- '</a>';
- }, '');
-
- var sLabel = '<span class="note-current-fontsize">11</span>';
- return renderer.getTemplate().button(sLabel, {
- title: lang.font.size,
- dropdown: '<div class="dropdown-menu">' + items + '</div>'
- });
-};
-
-renderer.tplButtonInfo.color = function (lang, options) {
- var foreColorButtonLabel = '<i class="' + options.iconPrefix + options.icons.color.recent + '"></i>';
- var backColorButtonLabel = '<i class="' + options.iconPrefix + 'paint-brush"></i>';
- // TODO Remove recent color button if possible.
- // It is still put to avoid JS errors when clicking other buttons as the
- // editor still expects it to exist.
- var recentColorButton = renderer.getTemplate().button(foreColorButtonLabel, {
- className: 'note-recent-color d-none',
- title: lang.color.foreground,
- event: 'color',
- value: '{"backColor":"#B35E9B"}'
- });
- var foreColorItems = [
- '<li><div class="btn-group flex-column">',
- '<div class="note-color-palette" data-target-event="foreColor"></div>',
- '<h6 class="note-custom-color mt8" data-event="customColor" data-value="foreColor" title="' + lang.color.custom + '">',
- lang.color.custom + '</h6>',
- '<div class="note-custom-color-palette" data-target-event="foreColor"></div>',
- '</div></li>',
- ];
- var backColorItems = [
- '<li><div class="btn-group flex-column">',
- '<div class="note-color-reset" data-event="backColor" data-value="inherit" title="' + lang.color.transparent + '">',
- lang.color.setTransparent + '</div>',
- '<div class="note-color-palette" data-target-event="backColor"></div>',
- '<h6 class="note-custom-color mt8" data-event="customColor" data-value="backColor" title="' + lang.color.custom + '">',
- lang.color.custom + '</h6>',
- '<div class="note-custom-color-palette" data-target-event="backColor"></div>',
- '</div></li>',
- ];
- var foreColorButton = renderer.getTemplate().button(foreColorButtonLabel, {
- className: 'note-fore-color-preview mx-1',
- title: lang.color.foreground,
- dropdown: renderer.getTemplate().dropdown(foreColorItems)
- });
- var backColorButton = renderer.getTemplate().button(backColorButtonLabel, {
- className: 'note-back-color-preview mx-1',
- title: lang.color.background,
- dropdown: renderer.getTemplate().dropdown(backColorItems)
- });
- return recentColorButton + foreColorButton + backColorButton;
-},
-
-//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
-
-key.nameFromCode[46] = 'DELETE';
-key.nameFromCode[27] = 'ESCAPE';
-
-options.keyMap.pc['BACKSPACE'] = 'backspace';
-options.keyMap.pc['DELETE'] = 'delete';
-options.keyMap.pc['ENTER'] = 'enter';
-options.keyMap.pc['ESCAPE'] = 'cancel';
-options.keyMap.mac['SHIFT+TAB'] = 'untab';
-options.keyMap.pc['UP'] = 'up';
-options.keyMap.pc['DOWN'] = 'down';
-
-options.keyMap.mac['BACKSPACE'] = 'backspace';
-options.keyMap.mac['DELETE'] = 'delete';
-options.keyMap.mac['ENTER'] = 'enter';
-options.keyMap.mac['ESCAPE'] = 'cancel';
-options.keyMap.mac['UP'] = 'up';
-options.keyMap.mac['DOWN'] = 'down';
-
-options.styleTags = ['p', 'pre', 'small', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'];
-
-$.summernote.pluginEvents.insertTable = function (event, editor, layoutInfo, sDim) {
- var $editable = layoutInfo.editable();
- var dimension = sDim.split('x');
- var r = range.create();
- if (!r) return;
- r = r.deleteContents(true);
-
- var table = editor.table.createTable(dimension[0], dimension[1]);
- var parent = r.sc;
- while (dom.isText(parent.parentNode) || dom.isRemovableEmptyNode(parent.parentNode)) {
- parent = parent.parentNode;
- }
- var node = dom.splitTree(parent, {'node': r.sc, 'offset': r.so}) || r.sc;
- node.parentNode.insertBefore(table, node);
-
- if ($(node).text() === '' || node.textContent === '\u00A0') {
- node.parentNode.removeChild(node);
- }
-
- editor.afterCommand($editable);
- event.preventDefault();
- return false;
-};
-$.summernote.pluginEvents.tab = function (event, editor, layoutInfo, outdent) {
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable, 'tab');
- var r = range.create();
- outdent = outdent || false;
- event.preventDefault();
-
- if (r && (dom.ancestor(r.sc, dom.isCell) || dom.ancestor(r.ec, dom.isCell))) {
- if (r.isCollapsed() && r.isOnCell() && r.isOnCellFirst()) {
- var td = dom.ancestor(r.sc, dom.isCell);
- if (!outdent && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode)) {
- var last = dom.lastChild(td);
- range.create(last, dom.nodeLength(last), last, dom.nodeLength(last)).select();
- $.summernote.pluginEvents.enter(event, editor, layoutInfo);
- } else if (outdent && !dom.previousElementSibling(td) && !$(td.parentNode).text().match(/\S/)) {
- $.summernote.pluginEvents.backspace(event, editor, layoutInfo);
- } else {
- editor.table.tab(r, outdent);
- }
- } else {
- $.summernote.pluginEvents.indent(event, editor, layoutInfo, outdent);
- }
- } else if (r && r.isCollapsed()) {
- if (!r.sc.textContent.slice(0,r.so).match(/\S/) && r.isOnList()) {
- if (outdent) {
- $.summernote.pluginEvents.outdent(event, editor, layoutInfo);
- } else {
- $.summernote.pluginEvents.indent(event, editor, layoutInfo);
- }
- } else {
- var next;
- if (!outdent) {
- if (dom.isText(r.sc)) {
- next = r.sc.splitText(r.so);
- } else {
- next = document.createTextNode('');
- $(r.sc.childNodes[r.so]).before(next);
- }
- editor.typing.insertTab($editable, r, options.tabsize);
- r = range.create(next, 0, next, 0);
- r = dom.merge(r.sc.parentNode, r.sc, r.so, r.ec, r.eo, null, true);
- range.create(r.sc, r.so, r.ec, r.eo).select();
- } else {
- r = dom.merge(r.sc.parentNode, r.sc, r.so, r.ec, r.eo, null, true);
- r = range.create(r.sc, r.so, r.ec, r.eo);
- if (r.sc.splitText) {
- next = r.sc.splitText(r.so);
- r.sc.textContent = r.sc.textContent.replace(/(\u00A0)+$/g, '');
- next.textContent = next.textContent.replace(/^(\u00A0)+/g, '');
- range.create(r.sc, r.sc.textContent.length, r.sc, r.sc.textContent.length).select();
- }
- }
- }
- }
- return false;
-};
-$.summernote.pluginEvents.untab = function (event, editor, layoutInfo) {
- return $.summernote.pluginEvents.tab(event, editor, layoutInfo, true);
-};
-$.summernote.pluginEvents.up = function (event, editor, layoutInfo) {
- var r = range.create();
- var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc);
- if (!r.isOnCell()) {
- return;
- }
- // check if an ancestor between node and cell has content before
- var ancestor = dom.ancestor(node, function (ancestorNode) {
- return dom.hasContentBefore(ancestorNode) || dom.isCell(ancestorNode);
- });
- if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentBefore(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentBefore(dom.hasContentBefore(ancestor)))) {
- return;
- }
- event.preventDefault();
- var td = dom.ancestor(r.sc, dom.isCell);
- var tr = td.parentNode;
- var target = tr.previousElementSibling && tr.previousElementSibling.children[_.indexOf(tr.children, td)];
- if (!target) {
- target = (dom.ancestorHavePreviousSibling(tr) || tr).previousSibling;
- }
- if (target) {
- range.create(dom.lastChild(target), dom.lastChild(target).textContent.length).select();
- }
-};
-$.summernote.pluginEvents.down = function (event, editor, layoutInfo) {
- var r = range.create();
- var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc);
- if (!r.isOnCell()) {
- return;
- }
- // check if an ancestor between node and cell has content after
- var ancestor = dom.ancestor(node, function (ancestorNode) {
- return dom.hasContentAfter(ancestorNode) || dom.isCell(ancestorNode);
- });
- if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentAfter(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentAfter(dom.hasContentAfter(ancestor)))) {
- return;
- }
- event.preventDefault();
- var td = dom.ancestor(r.sc, dom.isCell);
- var tr = td.parentNode;
- var target = tr.nextElementSibling && tr.nextElementSibling.children[_.indexOf(tr.children, td)];
- if (!target) {
- target = (dom.ancestorHaveNextSibling(tr) || tr).nextSibling;
- }
- if (target) {
- range.create(dom.firstChild(target), 0).select();
- }
-};
-$.summernote.pluginEvents.enter = function (event, editor, layoutInfo) {
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable, 'enter');
-
- var r = range.create();
- if (!r.isContentEditable()) {
- event.preventDefault();
- return false;
- }
- if (!r.isCollapsed()) {
- r = r.deleteContents();
- r.select();
- }
-
- var br = $("<br/>")[0];
-
- var node;
- var $node;
- var $clone;
- var contentBefore = r.sc.textContent.slice(0,r.so).match(/\S|\u00A0/);
- if (!contentBefore && dom.isText(r.sc)) {
- node = r.sc.previousSibling;
- while (!contentBefore && node && dom.isText(node)) {
- contentBefore = dom.isVisibleText(node);
- node = node.previousSibling;
- }
- }
-
- node = dom.node(r.sc);
- var exist = r.sc.childNodes[r.so] || r.sc;
- exist = dom.isVisibleText(exist) || dom.isBR(exist) ? exist : dom.hasContentAfter(exist) || (dom.hasContentBefore(exist) || exist);
-
- // table: add a tr
- var td = dom.ancestor(node, dom.isCell);
- if (td && !dom.nextElementSibling(node) && !dom.nextElementSibling(td) && !dom.nextElementSibling(td.parentNode) && (!dom.isText(r.sc) || !r.sc.textContent.slice(r.so).match(/\S|\u00A0/))) {
- $node = $(td.parentNode);
- $clone = $node.clone();
- $clone.children().html(dom.blank);
- $node.after($clone);
- node = dom.firstElementChild($clone[0]) || $clone[0];
- range.create(node, 0, node, 0).select();
- dom.scrollIntoViewIfNeeded(br);
- event.preventDefault();
- return false;
- }
-
- var last = node;
- while (node && dom.isSplitable(node) && !dom.isList(node)) {
- last = node;
- node = node.parentNode;
- }
-
- if (last === node && !dom.isBR(node)) {
- node = r.insertNode(br, true);
- if (isFormatNode(last.firstChild) && $(last).closest(options.styleTags.join(',')).length) {
- dom.moveContent(last.firstChild, last);
- last.removeChild(last.firstChild);
- }
- do {
- node = dom.hasContentAfter(node);
- } while (node && dom.isBR(node));
-
- // create an other br because the user can't see the new line with only br in a block
- if (!node && (!br.nextElementSibling || !dom.isBR(br.nextElementSibling))) {
- $(br).before($("<br/>")[0]);
- }
- node = br.nextSibling || br;
- } else if (last === node && dom.isBR(node)) {
- $(node).after(br);
- node = br;
- } else if (!r.so && r.isOnList() && !r.sc.textContent.length && !dom.ancestor(r.sc, dom.isLi).nextElementSibling) {
- // double enter on the end of a list = new line out of the list
- $('<p></p>').append(br).insertAfter(dom.ancestor(r.sc, dom.isList));
- node = br;
- } else if (dom.isBR(exist) && $(r.sc).closest('blockquote, pre').length && !dom.hasContentAfter($(exist.parentNode).closest('blockquote *, pre *').length ? exist.parentNode : exist)) {
- // double enter on the end of a blockquote & pre = new line out of the list
- $('<p></p>').append(br).insertAfter($(r.sc).closest('blockquote, pre'));
- node = br;
- } else if (dom.isEditable(dom.node(r.sc))) {
- // if we are directly in an editable, only SHIFT + ENTER should add a newline
- node = null;
- } else if (last === r.sc) {
- if (dom.isBR(last)) {
- last = last.parentNode;
- }
- $node = $(last);
- $clone = $node.clone().text("");
- $node.after($clone);
- node = dom.node(dom.firstElementChild($clone[0]) || $clone[0]);
- $(node).html(br);
- node = br;
- } else {
- node = dom.splitTree(last, {'node': r.sc, 'offset': r.so}) || r.sc;
- if (!contentBefore) {
- var cur = dom.node(dom.lastChild(node.previousSibling));
- if (!dom.isBR(cur)) {
- $(cur).html(br);
- }
- }
- if (!dom.isVisibleText(node)) {
- node = dom.firstChild(node);
- $(dom.node( dom.isBR(node) ? node.parentNode : node )).html(br);
- node = br;
- }
- }
-
- if (node) {
- node = dom.firstChild(node);
- if (dom.isBR(node)) {
- range.createFromNode(node).select();
- } else {
- range.create(node,0).select();
- }
- dom.scrollIntoViewIfNeeded(node);
- }
- event.preventDefault();
- return false;
-};
-$.summernote.pluginEvents.visible = function (event, editor, layoutInfo) {
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable, "visible");
-
- var r = range.create();
- if (!r) return;
-
- if (!r.isCollapsed()) {
- if ((dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) && dom.node(r.sc) !== dom.node(r.ec)) {
- remove_table_content(r);
- r = range.create(r.ec, 0);
- }
- r.select();
- }
-
- // don't write in forbidden tag (like span for font awsome)
- var node = dom.firstChild(r.sc.tagName && r.so ? r.sc.childNodes[r.so] || r.sc : r.sc);
- while (node.parentNode) {
- if (dom.isForbiddenNode(node)) {
- var text = node.previousSibling;
- if (text && dom.isText(text) && dom.isVisibleText(text)) {
- range.create(text, text.textContent.length, text, text.textContent.length).select();
- } else {
- text = node.parentNode.insertBefore(document.createTextNode( "." ), node);
- range.create(text, 1, text, 1).select();
- setTimeout(function () {
- var text = range.create().sc;
- text.textContent = text.textContent.replace(/^./, '');
- range.create(text, text.textContent.length, text, text.textContent.length).select();
- },0);
- }
- break;
- }
- node = node.parentNode;
- }
-
- return true;
-};
-
-function remove_table_content(r) {
- var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo);
- if (dom.isText(r.sc)) {
- r.sc.textContent = r.sc.textContent.slice(0, r.so);
- }
- if (dom.isText(r.ec)) {
- r.ec.textContent = r.ec.textContent.slice(r.eo);
- }
- for (var i in nodes) {
- var node = nodes[i];
- if (node === r.sc || node === r.ec || $.contains(node, r.sc) || $.contains(node, r.ec)) {
- continue;
- } else if (dom.isCell(node)) {
- $(node).html("<br/>");
- } else if (node.parentNode) {
- do {
- var parent = node.parentNode;
- parent.removeChild(node);
- node = parent;
- } while (!dom.isVisibleText(node) && !dom.firstElementChild(node) &&
- !dom.isCell(node) &&
- node.parentNode && !$(node.parentNode).hasClass('o_editable'));
- }
- }
- return false;
-}
-
-$.summernote.pluginEvents.delete = function (event, editor, layoutInfo) {
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable, "delete");
-
- var r = range.create();
- if (!r) return;
- if (!r.isContentEditable()) {
- event.preventDefault();
- return false;
- }
- if (!r.isCollapsed()) {
- if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) {
- remove_table_content(r);
- range.create(r.ec, 0).select();
- } else {
- r = r.deleteContents();
- r.select();
- }
- event.preventDefault();
- return false;
- }
-
- var target = r.ec;
- var offset = r.eo;
- if (target.tagName && target.childNodes[offset]) {
- target = target.childNodes[offset];
- offset = 0;
- }
-
- var node = dom.node(target);
- var data = dom.merge(node, target, offset, target, offset, null, true);
- data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo);
- r = range.create(data.sc, data.so);
- r.select();
- target = r.sc;
- offset = r.so;
-
- while (!dom.hasContentAfter(node) && !dom.hasContentBefore(node) && !dom.isImg(node)) {node = node.parentNode;}
-
- var contentAfter = target.textContent.slice(offset,Infinity).match(/\S|\u00A0/);
- var content = target.textContent.replace(/[ \t\r\n]+$/, '');
- var temp;
- var temp2;
- var next;
-
- // media
- if (dom.isImg(node) || (!contentAfter && dom.isImg(dom.hasContentAfter(node)))) {
- var parent;
- var index;
- if (!dom.isImg(node)) {
- node = dom.hasContentAfter(node);
- }
- while (dom.isImg(node)) {
- parent = node.parentNode;
- index = dom.position(node);
- if (index>0) {
- next = node.previousSibling;
- r = range.create(next, next.textContent.length);
- } else {
- r = range.create(parent, 0);
- }
- if (!dom.hasContentAfter(node) && !dom.hasContentBefore(node)) {
- parent.appendChild($('<br/>')[0]);
- }
- parent.removeChild(node);
- node = parent;
- r.select();
- }
- }
- // empty tag
- else if (!content.length && target.tagName && dom.isRemovableEmptyNode(dom.isBR(target) ? target.parentNode : target)) {
- if (node === $editable[0] || $.contains(node, $editable[0])) {
- event.preventDefault();
- return false;
- }
- var before = false;
- next = dom.hasContentAfter(dom.ancestorHaveNextSibling(node));
- if (!dom.isContentEditable(next)) {
- before = true;
- next = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node));
- }
- dom.removeSpace(next.parentNode, next, 0, next, 0); // clean before jump for not select invisible space between 2 tag
- next = dom.firstChild(next);
- node.parentNode.removeChild(node);
- range.create(next, before ? next.textContent.length : 0).select();
- }
- // normal feature if same tag and not the end
- else if (contentAfter) {
- return true;
- }
- // merge with the next text node
- else if (dom.isText(target) && (temp = dom.hasContentAfter(target)) && dom.isText(temp)) {
- return true;
- }
- //merge with the next block
- else if ((temp = dom.ancestorHaveNextSibling(target)) &&
- !r.isOnCell() &&
- dom.isMergable(temp) &&
- dom.isMergable(temp2 = dom.hasContentAfter(temp)) &&
- temp.tagName === temp2.tagName &&
- (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li
- !dom.isNotBreakable(temp) &&
- !dom.isNotBreakable(temp2)) {
- dom.autoMerge(target, false);
- next = dom.firstChild(dom.hasContentAfter(dom.ancestorHaveNextSibling(target)));
- if (dom.isBR(next)) {
- if (dom.position(next) === 0) {
- range.create(next.parentNode, 0).select();
- }
- else {
- range.create(next.previousSibling, next.previousSibling.textContent.length).select();
- }
- next.parentNode.removeChild(next);
- } else {
- range.create(next, 0).select();
- }
- }
- // jump to next node for delete
- else if ((temp = dom.ancestorHaveNextSibling(target)) && (temp2 = dom.hasContentAfter(temp)) && dom.isContentEditable(temp2)) {
-
- dom.removeSpace(temp2.parentNode, temp2, 0, temp, 0); // clean before jump for not select invisible space between 2 tag
- temp2 = dom.firstChild(temp2);
-
- r = range.create(temp2, 0);
- r.select();
-
- if ((dom.isText(temp) || dom.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || dom.getComputedStyle(temp2).display === "inline")) {
- if (dom.isText(temp2)) {
- temp2.textContent = temp2.textContent.replace(/^\s*\S/, '');
- } else {
- $.summernote.pluginEvents.delete(event, editor, layoutInfo);
- }
- }
- }
-
- $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
- event.preventDefault();
- return false;
-};
-$.summernote.pluginEvents.backspace = function (event, editor, layoutInfo) {
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable, "backspace");
-
- var r = range.create();
- if (!r) return;
- if (!r.isContentEditable()) {
- event.preventDefault();
- return false;
- }
- if (!r.isCollapsed()) {
- if (dom.isCell(dom.node(r.sc)) || dom.isCell(dom.node(r.ec))) {
- remove_table_content(r);
- range.create(r.sc, dom.nodeLength(r.sc)).select();
- } else {
- r = r.deleteContents();
- r.select();
- }
- event.preventDefault();
- return false;
- }
-
- var target = r.sc;
- var offset = r.so;
- if (target.tagName && target.childNodes[offset]) {
- target = target.childNodes[offset];
- offset = 0;
- }
-
- var node = dom.node(target);
- var data = dom.merge(node, target, offset, target, offset, null, true);
- data = dom.removeSpace(node.parentNode, data.sc, data.so, data.ec, data.eo);
- r = dom.isVoid(data.sc) ? range.createFromNode(data.sc) : range.create(data.sc, data.so);
- r.select();
- target = r.sc;
- offset = r.so;
- if (target.tagName && target.childNodes[offset]) {
- target = target.childNodes[offset];
- offset = 0;
- node = dom.node(target);
- }
-
- while (node.parentNode && !dom.hasContentAfter(node) && !dom.hasContentBefore(node) && !dom.isImg(node)) {node = node.parentNode;}
-
- var contentBefore = target.textContent.slice(0,offset).match(/\S|\u00A0/);
- var content = target.textContent.replace(/[ \t\r\n]+$/, '');
- var temp;
- var temp2;
- var prev;
-
- // delete media
- if (dom.isImg(node) || (!contentBefore && dom.isImg(dom.hasContentBefore(node)))) {
- if (!dom.isImg(node)) {
- node = dom.hasContentBefore(node);
- }
- range.createFromNode(node).select();
- $.summernote.pluginEvents.delete(event, editor, layoutInfo);
- }
- // table tr td
- else if (r.isOnCell() && !offset && (target === (temp = dom.ancestor(target, dom.isCell)) || target === temp.firstChild || (dom.isText(temp.firstChild) && !dom.isVisibleText(temp.firstChild) && target === temp.firstChild.nextSibling))) {
- if (dom.previousElementSibling(temp)) {
- var td = dom.previousElementSibling(temp);
- node = td.lastChild || td;
- } else {
- var tr = temp.parentNode;
- var prevTr = dom.previousElementSibling(tr);
- if (!$(temp.parentNode).text().match(/\S|\u00A0/)) {
- if (prevTr) {
- node = dom.lastChild(dom.lastElementChild(prevTr));
- } else {
- node = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling(tr)) || $editable.get(0));
- }
- $(tr).empty();
- if (!$(tr).closest('table').has('td, th').length) {
- $(tr).closest('table').remove();
- }
- $(tr).remove();
- range.create(node, node.textContent.length, node, node.textContent.length).select();
- } else {
- node = dom.lastElementChild(prevTr).lastChild || dom.lastElementChild(prevTr);
- }
- }
- if (dom.isBR(node)) {
- range.createFromNode(node).select();
- } else {
- range.create(node, dom.nodeLength(node)).select();
- }
- }
- // empty tag
- else if (!content.length && target.tagName && dom.isRemovableEmptyNode(target)) {
- if (node === $editable[0] || $.contains(node, $editable[0])) {
- event.preventDefault();
- return false;
- }
- var before = true;
- prev = dom.hasContentBefore(dom.ancestorHavePreviousSibling(node));
- if (!dom.isContentEditable(prev)) {
- before = false;
- prev = dom.hasContentAfter(dom.ancestorHaveNextSibling(node));
- }
- dom.removeSpace(prev.parentNode, prev, 0, prev, 0); // clean before jump for not select invisible space between 2 tag
- prev = dom.lastChild(prev);
- node.parentNode.removeChild(node);
- range.createFromNode(prev).select();
- range.create(prev, before ? prev.textContent.length : 0).select();
- }
- // normal feature if same tag and not the begin
- else if (contentBefore) {
- return true;
- }
- // merge with the previous text node
- else if (dom.isText(target) && (temp = dom.hasContentBefore(target)) && (dom.isText(temp) || dom.isBR(temp))) {
- return true;
- }
- //merge with the previous block
- else if ((temp = dom.ancestorHavePreviousSibling(target)) &&
- dom.isMergable(temp) &&
- dom.isMergable(temp2 = dom.hasContentBefore(temp)) &&
- temp.tagName === temp2.tagName &&
- (temp.tagName !== "LI" || !$('ul,ol', temp).length) && (temp2.tagName !== "LI" || !$('ul,ol', temp2).length) && // protect li
- !dom.isNotBreakable(temp) &&
- !dom.isNotBreakable(temp2)) {
- prev = dom.firstChild(target);
- dom.autoMerge(target, true);
- range.create(prev, 0).select();
- }
- // jump to previous node for delete
- else if ((temp = dom.ancestorHavePreviousSibling(target)) && (temp2 = dom.hasContentBefore(temp)) && dom.isContentEditable(temp2)) {
-
- dom.removeSpace(temp2.parentNode, temp2, 0, temp, 0); // clean before jump for not select invisible space between 2 tag
- temp2 = dom.lastChild(temp2);
-
- r = range.create(temp2, temp2.textContent.length, temp2, temp2.textContent.length);
- r.select();
-
- if ((dom.isText(temp) || dom.getComputedStyle(temp).display === "inline") && (dom.isText(temp2) || dom.getComputedStyle(temp2).display === "inline")) {
- if (dom.isText(temp2)) {
- temp2.textContent = temp2.textContent.replace(/\S\s*$/, '');
- } else {
- $.summernote.pluginEvents.backspace(event, editor, layoutInfo);
- }
- }
- }
-
- r = range.create();
- if (r) {
- $(dom.node(r.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
- dom.scrollIntoViewIfNeeded(r.sc.parentNode.previousElementSibling || r.sc);
- }
-
- event.preventDefault();
- return false;
-};
-
-function isFormatNode(node) {
- return node.tagName && options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1;
-}
-
-$.summernote.pluginEvents.insertUnorderedList = function (event, editor, layoutInfo, sorted) {
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable);
-
- var parent;
- var r = range.create();
- if (!r) return;
- var node = r.sc;
- while (node && node !== $editable[0]) {
-
- parent = node.parentNode;
- if (node.tagName === (sorted ? "UL" : "OL")) {
-
- var ul = document.createElement(sorted ? "ol" : "ul");
- ul.className = node.className;
- parent.insertBefore(ul, node);
- while (node.firstChild) {
- ul.appendChild(node.firstChild);
- }
- parent.removeChild(node);
- r.select();
- return;
-
- } else if (node.tagName === (sorted ? "OL" : "UL")) {
-
- var lis = [];
- for (var i=0; i<node.children.length; i++) {
- lis.push(node.children[i]);
- }
-
- if (parent.tagName === "LI") {
- node = parent;
- parent = node.parentNode;
- _.each(lis, function (li) {
- parent.insertBefore(li, node);
- });
- } else {
- _.each(lis, function (li) {
- while (li.firstChild) {
- parent.insertBefore(li.firstChild, node);
- }
- });
- }
-
- parent.removeChild(node);
- r.select();
- return;
-
- }
- node = parent;
- }
-
- var p0 = r.sc;
- while (p0 && p0.parentNode && p0.parentNode !== $editable[0] && !isFormatNode(p0)) {
- p0 = p0.parentNode;
- }
- if (!p0) return;
- var p1 = r.ec;
- while (p1 && p1.parentNode && p1.parentNode !== $editable[0] && !isFormatNode(p1)) {
- p1 = p1.parentNode;
- }
- if (!p0.parentNode || p0.parentNode !== p1.parentNode) {
- return;
- }
-
- parent = p0.parentNode;
- ul = document.createElement(sorted ? "ol" : "ul");
- parent.insertBefore(ul, p0);
- var childNodes = parent.childNodes;
- var brs = [];
- var begin = false;
- for (i = 0; i < childNodes.length; i++) {
- if (begin && dom.isBR(childNodes[i])) {
- parent.removeChild(childNodes[i]);
- i--;
- }
- if ((!dom.isText(childNodes[i]) && !isFormatNode(childNodes[i])) || (!ul.firstChild && childNodes[i] !== p0) ||
- $.contains(ul, childNodes[i]) || (dom.isText(childNodes[i]) && !childNodes[i].textContent.match(/\S|u00A0/))) {
- continue;
- }
- begin = true;
- var li = document.createElement('li');
- ul.appendChild(li);
- li.appendChild(childNodes[i]);
- if (li.firstChild === p1) {
- break;
- }
- i--;
- }
- if (dom.isBR(childNodes[i])) {
- parent.removeChild(childNodes[i]);
- }
-
- for (i = 0; i < brs.length ; i++) {
- parent.removeChild(brs[i]);
- }
- r.clean().select();
- event.preventDefault();
- return false;
-};
-$.summernote.pluginEvents.insertOrderedList = function (event, editor, layoutInfo) {
- return $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, true);
-};
-$.summernote.pluginEvents.indent = function (event, editor, layoutInfo, outdent) {
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable);
- var r = range.create();
- if (!r) return;
-
- var flag = false;
- function indentUL(UL, start, end) {
- var next;
- var tagName = UL.tagName;
- var node = UL.firstChild;
- var ul = document.createElement(tagName);
- var li = document.createElement("li");
- li.style.listStyle = "none";
- li.appendChild(ul);
-
- if (flag) {
- flag = 1;
- }
-
- // create and fill ul into a li
- while (node) {
- if (flag === 1 || node === start || $.contains(node, start)) {
- flag = true;
- node.parentNode.insertBefore(li, node);
- }
- next = dom.nextElementSibling(node);
- if (flag) {
- ul.appendChild(node);
- }
- if (node === end || $.contains(node, end)) {
- flag = false;
- break;
- }
- node = next;
- }
-
- var temp;
- var prev = dom.previousElementSibling(li);
- if (prev && prev.tagName === "LI" && (temp = dom.firstElementChild(prev)) && temp.tagName === tagName && ((dom.firstElementChild(prev) || prev.firstChild) !== ul)) {
- dom.doMerge(dom.firstElementChild(prev) || prev.firstChild, ul);
- li = prev;
- li.parentNode.removeChild(dom.nextElementSibling(li));
- }
- next = dom.nextElementSibling(li);
- if (next && next.tagName === "LI" && (temp = dom.firstElementChild(next)) && temp.tagName === tagName && (dom.firstElementChild(li) !== dom.firstElementChild(next))) {
- dom.doMerge(dom.firstElementChild(li), dom.firstElementChild(next));
- li.parentNode.removeChild(dom.nextElementSibling(li));
- }
- }
- function outdenttUL(UL, start, end) {
- var next;
- var node = UL.firstChild;
- var parent = UL.parentNode;
- var li = UL.parentNode.tagName === "LI" ? UL.parentNode : UL;
- var ul = UL.parentNode.tagName === "LI" ? UL.parentNode.parentNode : UL.parentNode;
- start = dom.ancestor(start, dom.isLi);
- end = dom.ancestor(end, dom.isLi);
-
- if (ul.tagName !== "UL" && ul.tagName !== "OL") return;
-
- // create and fill ul into a li
- while (node) {
- if (node === start || $.contains(node, start)) {
- flag = true;
- if (dom.previousElementSibling(node) && li.tagName === "LI") {
- li = dom.splitTree(li, dom.prevPoint({'node': node, 'offset': 0}));
- }
- }
- next = dom.nextElementSibling(node);
- if (flag) {
- ul = node.parentNode;
- li.parentNode.insertBefore(node, li);
- if (!ul.children.length) {
- if (ul.parentNode.tagName === "LI") {
- ul = ul.parentNode;
- }
- ul.parentNode.removeChild(ul);
- }
- }
-
- if (node === end || $.contains(node, end)) {
- flag = false;
- break;
- }
- node = next;
- }
-
- dom.merge(parent, start, 0, end, 1, null, true);
- }
- function indentOther(p, start, end) {
- if (p === start || $.contains(p, start) || $.contains(start, p)) {
- flag = true;
- }
- if (flag) {
- if (outdent) {
- dom.outdent(p);
- } else {
- dom.indent(p);
- }
- }
- if (p === end || $.contains(p, end) || $.contains(end, p)) {
- flag = false;
- }
- }
-
- var ancestor = r.commonAncestor();
- var $dom = $(ancestor);
-
- if (!dom.isList(ancestor)) {
- // to indent a selection, we indent the child nodes of the common
- // ancestor that contains this selection
- $dom = $(dom.node(ancestor)).children();
- }
- if (!$dom.not('br').length) {
- // if selection is inside a list, we indent its list items
- $dom = $(dom.ancestor(r.sc, dom.isList));
- if (!$dom.length) {
- // if the selection is contained in a single HTML node, we indent
- // the first ancestor 'content block' (P, H1, PRE, ...) or TD
- $dom = $(r.sc).closest(options.styleTags.join(',')+',td');
- }
- }
-
- // if select tr, take the first td
- $dom = $dom.map(function () { return this.tagName === "TR" ? dom.firstElementChild(this) : this; });
-
- $dom.each(function () {
- if (flag || $.contains(this, r.sc)) {
- if (dom.isList(this)) {
- if (outdent) {
- outdenttUL(this, r.sc, r.ec);
- } else {
- indentUL(this, r.sc, r.ec);
- }
- } else if (isFormatNode(this) || dom.ancestor(this, dom.isCell)) {
- indentOther(this, r.sc, r.ec);
- }
- }
- });
-
- if ($dom.length) {
- var $parent = $dom.parent();
-
- // remove text nodes between lists
- var $ul = $parent.find('ul, ol');
- if (!$ul.length) {
- $ul = $(dom.ancestor(r.sc, dom.isList));
- }
- $ul.each(function () {
- if (this.previousSibling &&
- this.previousSibling !== dom.previousElementSibling(this) &&
- !this.previousSibling.textContent.match(/\S/)) {
- this.parentNode.removeChild(this.previousSibling);
- }
- if (this.nextSibling &&
- this.nextSibling !== dom.nextElementSibling(this) &&
- !this.nextSibling.textContent.match(/\S/)) {
- this.parentNode.removeChild(this.nextSibling);
- }
- });
-
- // merge same ul or ol
- r = dom.merge($parent[0], r.sc, r.so, r.ec, r.eo, function (prev, cur) {
- if (prev && dom.isList(prev) && dom.isEqual(prev, cur)) {
- return true;
- }
- }, true);
- range.create(r.sc, r.so, r.ec, r.eo).select();
- }
- event.preventDefault();
- return false;
-};
-$.summernote.pluginEvents.outdent = function (event, editor, layoutInfo) {
- return $.summernote.pluginEvents.indent(event, editor, layoutInfo, true);
-};
-
-$.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTagName) {
- $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, null, "Default");
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable);
- event.preventDefault();
-
- var r = range.create();
- if (!r) {
- return;
- }
- // select content since container (that firefox selects) may be removed
- if (r.so === 0) {
- r.sc = dom.firstChild(r.sc);
- }
- if (dom.nodeLength(r.ec) >= r.eo) {
- r.ec = dom.lastChild(r.ec);
- r.eo = dom.nodeLength(r.ec);
- }
- r = range.create(r.sc, r.so, r.ec, r.eo);
- r.reRange().select();
-
- if (sTagName === "blockquote" || sTagName === "pre") {
- sTagName = $.summernote.core.agent.isMSIE ? '<' + sTagName + '>' : sTagName;
- document.execCommand('FormatBlock', false, sTagName);
- return;
- }
-
- // fix by odoo because if you select a style in a li with no p tag all the ul is wrapped by the style tag
- var nodes = dom.listBetween(r.sc, r.ec, r.so, r.eo);
- for (var i=0; i<nodes.length; i++) {
- if (dom.isBR(nodes[i]) || (dom.isText(nodes[i]) && dom.isVisibleText(nodes[i])) || dom.isB(nodes[i]) || dom.isU(nodes[i]) || dom.isS(nodes[i]) || dom.isI(nodes[i]) || dom.isFont(nodes[i])) {
- var ancestor = dom.ancestor(nodes[i], isFormatNode);
- if ($(ancestor).parent().is('blockquote')) {
- // firefox may wrap formatting block in blockquote
- $(ancestor).unwrap();
- }
- if (!ancestor) {
- dom.wrap(nodes[i], sTagName);
- } else if (ancestor.tagName.toLowerCase() !== sTagName) {
- var tag = document.createElement(sTagName);
- ancestor.parentNode.insertBefore(tag, ancestor);
- dom.moveContent(ancestor, tag);
- if (ancestor.className) {
- tag.className = ancestor.className;
- }
- ancestor.parentNode.removeChild(ancestor);
- }
- }
- }
- r.select();
-};
-$.summernote.pluginEvents.removeFormat = function (event, editor, layoutInfo, value) {
- var $editable = layoutInfo.editable();
- $editable.data('NoteHistory').recordUndo($editable);
- var r = range.create();
- if (!r) return;
- var node = range.create().sc.parentNode;
- document.execCommand('removeFormat');
- document.execCommand('removeFormat');
- r = range.create();
- if (!r) return;
- r = dom.merge(node, r.sc, r.so, r.ec, r.eo, null, true);
- range.create(r.sc, r.so, r.ec, r.eo).select();
- event.preventDefault();
- return false;
-};
-
-eventHandler.modules.editor.undo = function ($popover) {
- if (!$popover.attr('disabled')) $popover.data('NoteHistory').undo();
-};
-eventHandler.modules.editor.redo = function ($popover) {
- if (!$popover.attr('disabled')) $popover.data('NoteHistory').redo();
-};
-
-// Get color and background color of node to update recent color button
-var fn_from_node = eventHandler.modules.editor.style.fromNode;
-eventHandler.modules.editor.style.fromNode = function ($node) {
- var styleInfo = fn_from_node.apply(this, arguments);
- styleInfo['color'] = $node.css('color');
- styleInfo['background-color'] = $node.css('background-color');
- return styleInfo;
-};
-
-// use image toolbar if current range is on image
-var fn_editor_currentstyle = eventHandler.modules.editor.currentStyle;
-eventHandler.modules.editor.currentStyle = function (target) {
- var styleInfo = fn_editor_currentstyle.apply(this, arguments);
- // with our changes for inline editor, the targeted element could be a button of the editor
- if (!styleInfo.image || !dom.isEditable(styleInfo.image)) {
- styleInfo.image = undefined;
- var r = range.create();
- if (r)
- styleInfo.image = r.isOnImg();
- }
- // Fix when the target is a link: the text-align buttons state should
- // indicate the alignment of the link in the parent, not the text inside
- // the link (which is not possible to customize with summernote). Summernote fixed
- // this in their newest version... by just not showing the active button
- // for alignments.
- if (styleInfo.anchor) {
- styleInfo['text-align'] = $(styleInfo.anchor).parent().css('text-align');
- }
- return styleInfo;
-};
-
-options.fontSizes = [_t('Default'), 8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 62];
-$.summernote.pluginEvents.applyFont = function (event, editor, layoutInfo, color, bgcolor, size) {
- var r = range.create();
- if (!r) return;
- var startPoint = r.getStartPoint();
- var endPoint = r.getEndPoint();
-
- if (r.isCollapsed() && !dom.isFont(r.sc)) {
- return {
- sc: startPoint.node,
- so: startPoint.offset,
- ec: endPoint.node,
- offset: endPoint.offset
- };
- }
-
- if (startPoint.node.tagName && startPoint.node.childNodes[startPoint.offset]) {
- startPoint.node = startPoint.node.childNodes[startPoint.offset];
- startPoint.offset = 0;
- }
- if (endPoint.node.tagName && endPoint.node.childNodes[endPoint.offset]) {
- endPoint.node = endPoint.node.childNodes[endPoint.offset];
- endPoint.offset = 0;
- }
-
- // get first and last point
- var ancestor;
- var node;
- if (endPoint.offset && endPoint.offset !== dom.nodeLength(endPoint.node)) {
- ancestor = dom.ancestor(endPoint.node, dom.isFont) || endPoint.node;
- dom.splitTree(ancestor, endPoint);
- }
- if (startPoint.offset && startPoint.offset !== dom.nodeLength(startPoint.node)) {
- ancestor = dom.ancestor(startPoint.node, dom.isFont) || startPoint.node;
- node = dom.splitTree(ancestor, startPoint);
- if (endPoint.node === startPoint.node) {
- endPoint.node = node;
- endPoint.offset = dom.nodeLength(node);
- }
- startPoint.node = node;
- startPoint.offset = 0;
- }
-
- // get list of nodes to change
- var nodes = [];
- dom.walkPoint(startPoint, endPoint, function (point) {
- var node = point.node;
- if (((dom.isText(node) && dom.isVisibleText(node)) ||
- (dom.isFont(node) && !dom.isVisibleText(node))) &&
- (node !== endPoint.node || endPoint.offset)) {
-
- nodes.push(point.node);
-
- }
- });
- nodes = list.unique(nodes);
-
- // If ico fa
- if (r.isCollapsed()) {
- nodes.push(startPoint.node);
- }
-
- // apply font: foreColor, backColor, size (the color can be use a class text-... or bg-...)
- var font, $font, fonts = [], className;
- var i;
- if (color || bgcolor || size) {
- for (i=0; i<nodes.length; i++) {
- node = nodes[i];
-
- font = dom.ancestor(node, dom.isFont);
- if (!font) {
- if (node.textContent.match(/^[ ]|[ ]$/)) {
- node.textContent = node.textContent.replace(/^[ ]|[ ]$/g, '\u00A0');
- }
-
- font = dom.create("font");
- node.parentNode.insertBefore(font, node);
- font.appendChild(node);
- }
-
- fonts.push(font);
-
- className = font.className.split(/\s+/);
-
- var k;
- if (color) {
- for (k=0; k<className.length; k++) {
- if (className[k].length && className[k].slice(0,5) === "text-") {
- className.splice(k,1);
- k--;
- }
- }
-
- if (color.indexOf('text-') !== -1) {
- font.className = className.join(" ") + " " + color;
- font.style.color = "inherit";
- } else {
- font.className = className.join(" ");
- font.style.color = color;
- }
- }
- if (bgcolor) {
- for (k=0; k<className.length; k++) {
- if (className[k].length && className[k].slice(0,3) === "bg-") {
- className.splice(k,1);
- k--;
- }
- }
-
- if (bgcolor.indexOf('bg-') !== -1) {
- font.className = className.join(" ") + " " + bgcolor;
- font.style.backgroundColor = "inherit";
- } else {
- font.className = className.join(" ");
- font.style.backgroundColor = bgcolor;
- }
- }
- if (size) {
- font.style.fontSize = "inherit";
- if (!isNaN(size) && Math.abs(parseInt(dom.getComputedStyle(font).fontSize, 10)-size)/size > 0.05) {
- font.style.fontSize = size + "px";
- }
- }
- }
- }
-
- // remove empty values
- // we must remove the value in 2 steps (applay inherit then remove) because some
- // browser like chrome have some time an error for the rendering and/or keep inherit
- for (i=0; i<fonts.length; i++) {
- font = fonts[i];
- if (font.style.backgroundColor === "inherit") {
- font.style.backgroundColor = "";
- }
- if (font.style.color === "inherit") {
- font.style.color = "";
- }
- if (font.style.fontSize === "inherit") {
- font.style.fontSize = "";
- }
-
- $font = $(font);
-
- if (!$font.css("color") && !$font.css("background-color") && !$font.css("font-size")) {
- $font.removeAttr("style");
- }
- if (!font.className.length) {
- $font.removeAttr("class");
- }
- }
-
- // select nodes to clean (to remove empty font and merge same nodes)
- nodes = [];
- dom.walkPoint(startPoint, endPoint, function (point) {
- nodes.push(point.node);
- });
- nodes = list.unique(nodes);
-
- function remove(node, to) {
- if (node === endPoint.node) {
- endPoint = dom.prevPoint(endPoint);
- }
- if (to) {
- dom.moveContent(node, to);
- }
- dom.remove(node);
- }
-
- // remove node without attributes (move content), and merge the same nodes
- var className2, style, style2;
- for (i=0; i<nodes.length; i++) {
- node = nodes[i];
-
- if ((dom.isText(node) || dom.isBR(node)) && !dom.isVisibleText(node)) {
- remove(node);
- nodes.splice(i,1);
- i--;
- continue;
- }
-
- font = dom.ancestor(node, dom.isFont);
- node = font || dom.ancestor(node, dom.isSpan);
-
- if (!node) {
- continue;
- }
-
- $font = $(node);
- className = dom.orderClass(node);
- style = dom.orderStyle(node);
-
- if (!className && !style) {
- remove(node, node.parentNode);
- nodes.splice(i,1);
- i--;
- continue;
- }
-
- if (i>0 && (font = dom.ancestor(nodes[i-1], dom.isFont))) {
- className2 = font.getAttribute('class');
- style2 = font.getAttribute('style');
- if (node !== font && className === className2 && style === style2) {
- remove(node, font);
- nodes.splice(i,1);
- i--;
- continue;
- }
- }
- }
-
- range.create(startPoint.node, startPoint.offset, endPoint.node, endPoint.offset).select();
-};
-$.summernote.pluginEvents.fontSize = function (event, editor, layoutInfo, value) {
- var $editable = layoutInfo.editable();
- event.preventDefault();
- $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, null, value);
- editor.afterCommand($editable);
-};
-$.summernote.pluginEvents.color = function (event, editor, layoutInfo, sObjColor) {
- var oColor = JSON.parse(sObjColor);
- var foreColor = oColor.foreColor, backColor = oColor.backColor;
-
- if (foreColor) { $.summernote.pluginEvents.foreColor(event, editor, layoutInfo, foreColor); }
- if (backColor) { $.summernote.pluginEvents.backColor(event, editor, layoutInfo, backColor); }
-};
-$.summernote.pluginEvents.foreColor = function (event, editor, layoutInfo, foreColor) {
- var $editable = layoutInfo.editable();
- $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, foreColor, null, null);
- editor.afterCommand($editable);
-};
-$.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backColor) {
- var $editable = layoutInfo.editable();
- var r = range.create();
- if (!r) return;
- if (r.isCollapsed() && r.isOnCell()) {
- var cell = dom.ancestor(r.sc, dom.isCell);
- cell.className = cell.className.replace(new RegExp('(^|\\s+)bg-[^\\s]+(\\s+|$)', 'gi'), '');
- cell.style.backgroundColor = "";
- if (backColor.indexOf('bg-') !== -1) {
- cell.className += ' ' + backColor;
- } else if (backColor !== 'inherit') {
- cell.style.backgroundColor = backColor;
- }
- return;
- }
- $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, null, backColor, null);
- editor.afterCommand($editable);
-};
-
-options.onCreateLink = function (sLinkUrl) {
- if (sLinkUrl.indexOf('mailto:') === 0 || sLinkUrl.indexOf('tel:') === 0) {
- // pass
- } else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) {
- sLinkUrl = 'mailto:' + sLinkUrl;
- } else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl[0] !== '/'
- && sLinkUrl[0] !== '#' && sLinkUrl.slice(0, 2) !== '${') {
- sLinkUrl = 'http://' + sLinkUrl;
- }
- return sLinkUrl;
-};
-
-function summernote_table_scroll(event) {
- var r = range.create();
- if (r && r.isOnCell()) {
- $('.o_table_handler').remove();
- }
-}
-function summernote_table_update(oStyle) {
- var r = range.create();
- if (!oStyle.range || !r || !r.isOnCell() || !r.isOnCellFirst()) {
- $('.o_table_handler').remove();
- return;
- }
- var table = dom.ancestor(oStyle.range.sc, dom.isTable);
- if (!table) { // if the editable tag is inside the table
- return;
- }
- var $editable = $(table).closest('.o_editable');
-
- $('.o_table_handler').remove();
-
- var $dels = $();
- var $adds = $();
- var $tds = $('tr:first', table).children();
- $tds.each(function () {
- var $td = $(this);
- var pos = $td.offset();
-
- var $del = $('<span class="o_table_handler fa fa-minus-square"/>').appendTo('body');
- $del.data('td', this);
- $dels = $dels.add($del);
- $del.css({
- left: ((pos.left + $td.outerWidth()/2)-6) + "px",
- top: (pos.top-6) + "px"
- });
-
- var $add = $('<span class="o_table_handler fa fa-plus-square"/>').appendTo('body');
- $add.data('td', this);
- $adds = $adds.add($add);
- $add.css({
- left: (pos.left-6) + "px",
- top: (pos.top-6) + "px"
- });
- });
-
- var $last = $tds.last();
- var pos = $last.offset();
- var $add = $('<span class="o_table_handler fa fa-plus-square"/>').appendTo('body');
- $adds = $adds.add($add);
- $add.css({
- left: (pos.left+$last.outerWidth()-6) + "px",
- top: (pos.top-6) + "px"
- });
-
- var $table = $(table);
- $dels.data('table', table).on('mousedown', function (event) {
- var td = $(this).data('td');
- $editable.data('NoteHistory').recordUndo($editable);
-
- var newTd;
- if ($(td).siblings().length) {
- var eq = $(td).index();
- $table.find('tr').each(function () {
- $('> td:eq('+eq+')', this).remove();
- });
- newTd = $table.find('tr:first > td:eq('+eq+'), tr:first > td:last').first();
- } else {
- var prev = dom.lastChild(dom.hasContentBefore(dom.ancestorHavePreviousSibling($table[0])));
- $table.remove();
- $('.o_table_handler').remove();
- r = range.create(prev, prev.textContent.length);
- r.select();
- $(r.sc).trigger('mouseup');
- return;
- }
-
- $('.o_table_handler').remove();
- range.create(newTd[0], 0, newTd[0], 0).select();
- newTd.trigger('mouseup');
- });
- $adds.data('table', table).on('mousedown', function (event) {
- var td = $(this).data('td');
- $editable.data('NoteHistory').recordUndo($editable);
-
- var newTd;
- if (td) {
- var eq = $(td).index();
- $table.find('tr').each(function () {
- $('td:eq('+eq+')', this).before('<td>'+dom.blank+'</td>');
- });
- newTd = $table.find('tr:first td:eq('+eq+')');
- } else {
- $table.find('tr').each(function () {
- $(this).append('<td>'+dom.blank+'</td>');
- });
- newTd = $table.find('tr:first td:last');
- }
-
- $('.o_table_handler').remove();
- range.create(newTd[0], 0, newTd[0], 0).select();
- newTd.trigger('mouseup');
- });
-
- $dels.css({
- 'position': 'absolute',
- 'cursor': 'pointer',
- 'background-color': '#fff',
- 'color': '#ff0000'
- });
- $adds.css({
- 'position': 'absolute',
- 'cursor': 'pointer',
- 'background-color': '#fff',
- 'color': '#00ff00'
- });
-}
-var fn_popover_update = eventHandler.modules.popover.update;
-eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) {
- fn_popover_update.call(this, $popover, oStyle, isAirMode);
- if ((isAirMode ? $popover : $popover.parent()).find('.note-table').length) {
- summernote_table_update(oStyle);
- }
-};
-
-var fn_attach = eventHandler.attach;
-eventHandler.attach = function (oLayoutInfo, options) {
- var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable');
- fn_attach.call(this, oLayoutInfo, options);
- $editable.on("scroll", summernote_table_scroll);
-};
-var fn_detach = eventHandler.detach;
-eventHandler.detach = function (oLayoutInfo, options) {
- var $editable = oLayoutInfo.editor().hasClass('note-editable') ? oLayoutInfo.editor() : oLayoutInfo.editor().find('.note-editable');
- fn_detach.call(this, oLayoutInfo, options);
- $editable.off("scroll", summernote_table_scroll);
- $('.o_table_handler').remove();
-};
-
-options.icons.image.image = "file-image-o";
-$.summernote.lang['en-US'].image.image = "File / Image";
-
-return $.summernote;
-});
diff --git a/addons/web_editor/static/src/js/editor/translator.js b/addons/web_editor/static/src/js/editor/translator.js
deleted file mode 100644
index c6cadb40562..00000000000
--- a/addons/web_editor/static/src/js/editor/translator.js
+++ /dev/null
@@ -1,375 +0,0 @@
-odoo.define('web_editor.translate', function (require) {
-'use strict';
-
-var core = require('web.core');
-var Dialog = require('web.Dialog');
-var localStorage = require('web.local_storage');
-var Widget = require('web.Widget');
-var weContext = require('web_editor.context');
-var rte = require('web_editor.rte');
-var weWidgets = require('web_editor.widget');
-var Dialog = require('web.Dialog');
-
-var _t = core._t;
-
-var localStorageNoDialogKey = 'website_translator_nodialog';
-
-var RTETranslatorWidget = rte.Class.extend({
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * If the element holds a translation, saves it. Otherwise, fallback to the
- * standard saving but with the lang kept.
- *
- * @override
- */
- _saveElement: function ($el, context, withLang) {
- var self = this;
- if ($el.data('oe-translation-id')) {
- return this._rpc({
- model: 'ir.translation',
- method: 'save_html',
- args: [
- [+$el.data('oe-translation-id')],
- this._getEscapedElement($el).html()
- ],
- context: context,
- }).fail(function (error) {
- Dialog.alert(null, error.data.message);
- });
- }
- return this._super($el, context, withLang === undefined ? true : withLang);
- },
-});
-
-var AttributeTranslateDialog = weWidgets.Dialog.extend({
- /**
- * @constructor
- */
- init: function (parent, options, node) {
- this._super(parent, _.extend({
- title: _t("Translate Attribute"),
- buttons: [
- {text: _t("Close"), classes: 'btn-primary', click: this.save}
- ],
- }, options || {}));
- this.$target = $(node);
- this.translation = $(node).data('translation');
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
- var $group = $('<div/>', {class: 'form-group'}).appendTo(this.$el);
- _.each(this.translation, function (node, attr) {
- var $node = $(node);
- var $label = $('<label class="col-form-label"></label>').text(attr);
- var $input = $('<input class="form-control"/>').val($node.html());
- $input.on('change keyup', function () {
- var value = $input.val();
- $node.html(value).trigger('change', node);
- $node.data('$node').attr($node.data('attribute'), value).trigger('translate');
- self.trigger_up('rte_change', {target: node});
- });
- $group.append($label).append($input);
- });
- return this._super.apply(this, arguments);
- }
-});
-
-var TranslatorInfoDialog = Dialog.extend({
- template: 'web_editor.TranslatorInfoDialog',
- xmlDependencies: Dialog.prototype.xmlDependencies.concat(
- ['/web_editor/static/src/xml/translator.xml']
- ),
-
- /**
- * @constructor
- */
- init: function (parent, options) {
- this._super(parent, _.extend({
- title: _t("Translation Info"),
- buttons: [
- {text: _t("Ok, never show me this again"), classes: 'btn-primary', close: true, click: this._onStrongOk.bind(this)},
- {text: _t("Ok"), close: true}
- ],
- }, options || {}));
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the "strong" ok is clicked -> adapt localstorage to make sure
- * the dialog is never displayed again.
- *
- * @private
- */
- _onStrongOk: function () {
- localStorage.setItem(localStorageNoDialogKey, true);
- },
-});
-
-var TranslatorMenuBar = Widget.extend({
- template: 'web_editor.editorbar',
- xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
- events: {
- 'click [data-action="save"]': '_onSaveClick',
- 'click [data-action="cancel"]': '_onCancelClick',
- },
- custom_events: {
- 'rte_change': '_onRTEChange',
- },
-
- /**
- * @constructor
- */
- init: function (parent, $target, lang) {
- this._super.apply(this, arguments);
-
- var $edit = $target.find('[data-oe-translation-id], [data-oe-model][data-oe-id][data-oe-field]');
- $edit.filter(':has([data-oe-translation-id], [data-oe-model][data-oe-id][data-oe-field])').attr('data-oe-readonly', true);
- this.$target = $edit.not('[data-oe-readonly]');
- var attrs = ['placeholder', 'title', 'alt'];
- _.each(attrs, function (attr) {
- $target.find('['+attr+'*="data-oe-translation-id="]').filter(':empty, input, select, textarea, img').each(function () {
- var $node = $(this);
- var translation = $node.data('translation') || {};
- var trans = $node.attr(attr);
- var match = trans.match(/<span [^>]*data-oe-translation-id="([0-9]+)"[^>]*>(.*)<\/span>/);
- var $trans = $(trans).addClass('d-none o_editable o_editable_translatable_attribute').appendTo('body');
- $trans.data('$node', $node).data('attribute', attr);
- translation[attr] = $trans[0];
- $node.attr(attr, match[2]);
-
- var select2 = $node.data('select2');
- if (select2) {
- select2.blur();
- $node.on('translate', function () {
- select2.blur();
- });
- $node = select2.container.find('input');
- }
- $node.addClass('o_translatable_attribute').data('translation', translation);
- });
- });
- this.$target_attr = $target.find('.o_translatable_attribute');
- this.$target_attribute = $('.o_editable_translatable_attribute');
-
- this.lang = lang || weContext.get().lang;
-
- this.rte = new RTETranslatorWidget(this, this._getRTEConfig);
- },
- /**
- * @override
- */
- start: function () {
- this.$('#web_editor-toolbars').remove();
-
- var flag = false;
- window.onbeforeunload = function (event) {
- if ($('.o_editable.o_dirty').length && !flag) {
- flag = true;
- setTimeout(function () {
- flag = false;
- }, 0);
- return _t('This document is not saved!');
- }
- };
- this.$target.addClass('o_editable');
- this.rte.start();
- this.translations = [];
- this._markTranslatableNodes();
- this.$el.show(); // TODO seems useless
- this.trigger('edit');
-
- if (!localStorage.getItem(localStorageNoDialogKey)) {
- new TranslatorInfoDialog(this).open();
- }
-
- return this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- destroy: function () {
- this._cancel();
- this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Leaves translation mode by hiding the translator bar.
- *
- * @todo should be destroyed?
- * @private
- */
- _cancel: function () {
- var self = this;
- this.rte.cancel();
- this.$target.each(function () {
- $(this).html(self._getTranlationObject(this).value);
- });
- this._unmarkTranslatableNode();
- this.trigger('cancel');
- this.$el.hide();
- window.onbeforeunload = null;
- },
- /**
- * Returns the RTE summernote configuration for translation mode.
- *
- * @private
- * @param {jQuery} $editable
- */
- _getRTEConfig: function ($editable) {
- return {
- airMode : true,
- focus: false,
- airPopover: $editable.data('oe-model') ? [
- ['history', ['undo', 'redo']],
- ] : [
- ['font', ['bold', 'italic', 'underline', 'clear']],
- ['fontsize', ['fontsize']],
- ['color', ['color']],
- ['history', ['undo', 'redo']],
- ],
- styleWithSpan: false,
- inlinemedia : ['p'],
- lang: 'odoo',
- onChange: function (html, $editable) {
- $editable.trigger('content_changed');
- },
- };
- },
- /**
- * @private
- */
- _getTranlationObject: function (node) {
- var $node = $(node);
- var id = +$node.data('oe-translation-id');
- if (!id) {
- id = $node.data('oe-model')+','+$node.data('oe-id')+','+$node.data('oe-field');
- }
- var trans = _.find(this.translations, function (trans) {
- return trans.id === id;
- });
- if (!trans) {
- this.translations.push(trans = {'id': id});
- }
- return trans;
- },
- /**
- * @private
- */
- _markTranslatableNodes: function () {
- var self = this;
- this.$target.add(this.$target_attribute).each(function () {
- var $node = $(this);
- var trans = self._getTranlationObject(this);
- trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' ');
- });
- this.$target.parent().prependEvent('click.translator', function (ev) {
- if (ev.ctrlKey || !$(ev.target).is(':o_editable')) {
- return;
- }
- ev.preventDefault();
- ev.stopPropagation();
- });
-
- // attributes
-
- this.$target_attr.each(function () {
- var $node = $(this);
- var translation = $node.data('translation');
- _.each(translation, function (node, attr) {
- var trans = self._getTranlationObject(node);
- trans.value = (trans.value ? trans.value : $node.html() ).replace(/[ \t\n\r]+/, ' ');
- $node.attr('data-oe-translation-state', (trans.state || 'to_translate'));
- });
- });
-
- this.$target_attr.prependEvent('mousedown.translator click.translator mouseup.translator', function (ev) {
- if (ev.ctrlKey) {
- return;
- }
- ev.preventDefault();
- ev.stopPropagation();
- if (ev.type !== 'mousedown') {
- return;
- }
-
- new AttributeTranslateDialog(self, {}, ev.target).open();
- });
- },
- /**
- * Saves the translation and reloads the page to leave edit mode.
- *
- * @private
- * @returns {Deferred} (never resolved as the page is reloading anyway)
- */
- _save: function () {
- return this.rte.save(weContext.get({lang: this.lang})).then(function () {
- window.location.href = window.location.href.replace(/&?edit_translations(=[^&]*)?/g, '');
- return $.Deferred();
- });
- },
- /**
- * @private
- */
- _unmarkTranslatableNode: function () {
- this.$target.removeClass('o_editable').removeAttr('contentEditable');
- this.$target.parent().off('.translator');
- this.$target_attr.off('.translator');
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when the "cancel" button is clicked -> undo changes and leaves
- * edition.
- *
- * @private
- */
- _onCancelClick: function () {
- this._cancel();
- },
- /**
- * Called when text is edited -> make sure text is not messed up and mark
- * the element as dirty.
- *
- * @private
- * @param {OdooEvent} ev
- */
- _onRTEChange: function (ev) {
- var $node = $(ev.data.target);
- $node.find('p').each(function () { // remove <p/> element which might have been inserted because of copy-paste
- var $p = $(this);
- $p.after($p.html()).remove();
- });
- var trans = this._getTranlationObject($node[0]);
- $node.toggleClass('o_dirty', trans.value !== $node.html().replace(/[ \t\n\r]+/, ' '));
- },
- /**
- * Called when the "save" button is clicked -> saves the translations.
- *
- * @private
- */
- _onSaveClick: function () {
- this._save();
- },
-});
-
-return {
- Class: TranslatorMenuBar,
-};
-});
diff --git a/addons/web_editor/static/src/js/iframe.js b/addons/web_editor/static/src/js/iframe.js
deleted file mode 100644
index f2c5e3be661..00000000000
--- a/addons/web_editor/static/src/js/iframe.js
+++ /dev/null
@@ -1,153 +0,0 @@
-odoo.define('web_editor.iframe', function (require) {
-'use strict';
-
-var core = require('web.core');
-var editor = require('web_editor.editor');
-var translator = require('web_editor.translate');
-var rte = require('web_editor.rte');
-
-var callback = window ? window['callback'] : undefined;
-window.top.odoo[callback + '_updown'] = function (value, fields_values, field_name) {
- var $editable = $('#editable_area');
- if (value === $editable.prop('innerHTML')) {
- return;
- }
-
- if ($('body').hasClass('editor_enable')) {
- if (value !== fields_values[field_name]) {
- rte.history.recordUndo($editable);
- }
- core.bus.trigger('deactivate_snippet');
- }
-
- $editable.html(value);
-
- if ($('body').hasClass('editor_enable') && value !== fields_values[field_name]) {
- $editable.trigger('content_changed');
- }
-};
-
-editor.Class.include({
- start: function () {
- this.on('rte:start', this, function () {
- this.$('form').hide();
-
- if (window.top.odoo[callback + '_editor']) {
- window.top.odoo[callback + '_editor'](this);
- }
-
- var $editable = $('#editable_area');
- setTimeout(function () {
- $($editable.find('*').filter(function () {return !this.children.length;}).first()[0] || $editable)
- .focusIn().trigger('mousedown').trigger('keyup');
- },0);
-
- $editable.on('content_changed', this, function () {
- if (window.top.odoo[callback + '_downup']) {
- window.top.odoo[callback + '_downup']($editable.prop('innerHTML'));
- }
- });
- });
-
- return this._super.apply(this, arguments).then(function () {
- $(window.top).trigger('resize'); // TODO check, probably useless
- });
- }
-});
-
-rte.Class.include({
- /**
- * @override
- */
- _getDefaultConfig: function ($editable) {
- var config = this._super.apply(this, arguments);
- if ($.deparam($.param.querystring()).debug !== undefined) {
- config.airPopover.splice(7, 0, ['view', ['codeview']]);
- }
- return config;
- },
-});
-
-translator.Class.include({
- start: function () {
- var res = this._super.apply(this, arguments);
- $('button[data-action=save]').hide();
- if (window.top.odoo[callback + '_editor']) {
- window.top.odoo[callback + '_editor'](this);
- }
- return res;
- },
-});
-});
-
-//==============================================================================
-
-odoo.define('root.widget', function (require) {
-'use strict';
-
-require('web.dom_ready');
-var iframeRootData = require('web_editor.IframeRoot');
-
-var iframeRoot = new iframeRootData.IframeRoot(null);
-return iframeRoot.attachTo(document.body).then(function () {
- return iframeRoot;
-});
-});
-
-//==============================================================================
-
-odoo.define('web_editor.IframeRoot', function (require) {
-'use strict';
-
-var BodyManager = require('web_editor.BodyManager');
-var weContext = require('web_editor.context');
-var editor = require('web_editor.editor');
-var rootWidget = require('web_editor.root_widget');
-var translate = require('web_editor.translate');
-
-var iframeRootRegistry = new rootWidget.RootWidgetRegistry();
-
-var IframeRoot = BodyManager.extend({
- /**
- * @override
- * @todo this is somehow a duplicate of website features
- */
- start: function () {
- var defs = [this._super.apply(this, arguments)];
-
- var ctx = weContext.getExtra();
-
- if (ctx.editable && window.location.search.indexOf('enable_editor') >= 0) {
- var editorInstance = new (editor.Class)(this);
- defs.push(editorInstance.prependTo(this.$el));
- }
-
- if (ctx.edit_translations) {
- var translator = new (translate.Class)(this, this.$('#wrapwrap'));
- defs.push(translator.prependTo(this.$el));
- }
-
- return $.when.apply($, defs);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * As the IframeRoot instance is designed to be unique, the associated
- * registry has been instantiated outside of the class and is simply
- * returned here.
- *
- * @override
- */
- _getRegistry: function () {
- return iframeRootRegistry;
- },
-});
-
-return {
- IframeRoot: IframeRoot,
- iframeRootRegistry: iframeRootRegistry,
-};
-});
diff --git a/addons/web_editor/static/src/js/inline.js b/addons/web_editor/static/src/js/inline.js
deleted file mode 100644
index fb2247313bd..00000000000
--- a/addons/web_editor/static/src/js/inline.js
+++ /dev/null
@@ -1,91 +0,0 @@
-odoo.define('web_editor.inline', function (require) {
-'use strict';
-
-var core = require('web.core');
-var editor = require('web_editor.editor');
-var rte = require('web_editor.rte');
-var weWidgets = require('web_editor.widget');
-var transcoder = require('web_editor.transcoder');
-var snippet_editor = require('web_editor.snippet.editor');
-
-weWidgets.MediaDialog.include({
- start: function () {
- this.$('[href="#editor-media-video"]').addClass('d-none');
- return this._super.apply(this, arguments);
- },
-});
-
-editor.Class.include({
- start: function () {
- if (window.location.search.indexOf('enable_editor') !== -1) {
- this.on('rte:start', this, function () {
- // move the caret at the end of the text when click after all content
- $('#wrapwrap').on('click', function (event) {
- if ($(event.target).is('#wrapwrap') || $(event.target).is('#editable_area:empty')) {
- _.defer(function () {
- var node = $('#editable_area *')
- .filter(function () { return this.textContent.match(/\S|\u00A0/); })
- .add($('#editable_area'))
- .last()[0];
- $.summernote.core.range.create(node, $.summernote.core.dom.nodeLength(node)).select();
- });
- }
- });
- });
- }
- return this._super.apply(this, arguments);
- },
-});
-
-snippet_editor.Class.include({
- start: function () {
- _.defer(function () {
- var $editable = $('#editable_area');
- transcoder.linkImgToAttachmentThumbnail($editable);
- transcoder.imgToFont($editable);
- transcoder.styleToClass($editable);
-
- // fix outlook image rendering bug
- $editable.find('img[style*="width"], img[style*="height"]').removeAttr('height width');
- });
- return this._super.apply(this, arguments);
- },
- cleanForSave: function () {
- this._super.apply(this, arguments);
-
- var $editable = $('#editable_area');
- transcoder.attachmentThumbnailToLinkImg($editable);
- transcoder.fontToImg($editable);
- transcoder.classToStyle($editable);
-
- // fix outlook image rendering bug
- _.each(['width', 'height'], function (attribute) {
- $editable.find('img[style*="width"], img[style*="height"]').attr(attribute, function () {
- return $(this)[attribute]();
- }).css(attribute, function () {
- return $(this).get(0).style[attribute] || 'auto';
- });
- });
- },
-});
-
-var callback = window ? window['callback'] : undefined;
-window.top.odoo[callback + '_updown'] = function (value, fields_values) {
- var $editable = $('#editable_area');
- value = value || '';
- if (value.indexOf('on_change_model_and_list') === -1 && value !== $editable.html()) {
- rte.history.recordUndo($editable, null, true);
- core.bus.trigger('deactivate_snippet');
-
- $editable.html(value);
-
- transcoder.imgToFont($editable);
- transcoder.styleToClass($editable);
-
- // fix outlook image rendering bug
- $editable.find('img[style*="width"], img[style*="height"]').removeAttr('height width');
- } else {
- $editable.trigger('content_changed');
- }
-};
-});
diff --git a/addons/web_editor/static/src/js/tours/rte.js b/addons/web_editor/static/src/js/tours/rte.js
deleted file mode 100644
index abdcd39f35b..00000000000
--- a/addons/web_editor/static/src/js/tours/rte.js
+++ /dev/null
@@ -1,325 +0,0 @@
-odoo.define('web_editor.tour', function (require) {
-'use strict';
-
-var core = require('web.core');
-var tour = require('web_tour.tour');
-var base = require('web_editor.base');
-
-/**
- * Simulates a click event of given type on the given element.
- *
- * @param {DOMElement} el
- * @param {string} type - 'click', 'mouseup', ...
- */
-function simulateClickEvent(el, type) {
- var evt = document.createEvent('MouseEvents');
- evt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el);
- el.dispatchEvent(evt);
-}
-
-tour.register('rte', {
- url: '/web_editor/field/html?callback=FieldTextHtml_0&enable_editor=1&datarecord=%7B%7D',
- test: true,
- wait_for: base.ready(),
-}, [{
- content: "Change html for this test",
- trigger: "#editable_area",
- run: function () {
- var html = '\n'+
- '<section>\n'+
- ' <div class="container" style="width: 600px;">\n'+
- ' <div class="row">\n'+
- ' <div class="col-lg-6 mt16">\n'+
- '<h1 id="text_content_id">Batnae municipium in Anthemusia</h1> \n'+
- ' <p>Batnae municipium in Anthemusia conditum Macedonum manu priscorum ab Euphrate flumine brevi spatio disparatur, refertum mercatoribus opulentis, ubi annua sollemnitate prope Septembris initium mensis ad.</p>\n'+
- ' <p> Quam <img style="width: 25%" src="/web/static/src/img/logo.png"/> quidem <span class="fa fa-flag fa-2x"></span> partem accusationis admiratus sum et moleste tuli potissimum esse Atratino datam. Neque enim decebat neque aetas.</p>\n'+
- ' <p>Et hanc quidem praeter oppida multa duae civitates exornant Seleucia opus Seleuci regis, et Claudiopolis quam deduxit coloniam Claudius Caesar. Isaura enim antehac nimium potens, olim subversa ut rebellatrix.</p>'+
- '<p>Harum trium sententiarum nulli prorsus assentior.</p>\n'+
- ' </div>\n'+
- ' <div class="col-lg-6 mt16">\n'+
- ' <img class="img img-fluid shadow mb16" src="/web/static/src/img/logo.png" alt="Odoo text and image block">\n'+
- ' </div>\n'+
- ' </div>\n'+
- '</section>\n';
- this.$anchor.html(html);
- }
-}, {
- content: "simulate triple click and change text bg-color",
- trigger: '#editable_area > section .row > div:first',
- run: function () {
- var $h1 = $('h1', this.$anchor);
- $.summernote.core.range.create($h1[0].firstChild, 0, $('p', this.$anchor)[0], 0).select();
- simulateClickEvent($h1[0], 'mouseup');
- }
-}, {
- content: "change text bg-color after triple click",
- trigger: '.note-color button.dropdown-toggle:has(.fa-paint-brush)',
- extra_trigger: '#editable_area > section .row > div:first',
-}, {
- content: "change text backColor",
- trigger: "button[data-event=backColor]:visible:eq(2)",
-}, {
- content: "change selection to change text color",
- trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1 font',
- run: function () {
- $.summernote.core.range.create(this.$anchor[0].firstChild, 5, this.$anchor[0].firstChild, 10).select();
- simulateClickEvent(this.$anchor[0], 'mouseup');
- }
-}, {
- content: "open color dropdown",
- trigger: ".note-color button.dropdown-toggle:has(.fa-font)",
-}, {
- content: "change text color",
- trigger: "button[data-event=foreColor]:visible:first",
-}, {
- content: "change selection to change text bg-color again",
- trigger: '#editable_area > section .row > div:first h1 font:eq(2)',
- run: function () {
- $.summernote.core.range.create(this.$anchor.prev()[0].firstChild, 3, this.$anchor[0].firstChild, 10).select();
- simulateClickEvent(this.$anchor.prev()[0], 'mouseup');
- }
-}, {
- content: "open color dropdown",
- trigger: ".note-color button.dropdown-toggle:has(.fa-paint-brush)",
-}, {
- content: "change text backColor again",
- trigger: "button[data-event=backColor]:visible:first",
-}, {
- content: "change selection (h1 and p) to change text color with class",
- trigger: '#editable_area > section .row > div:first h1 font:eq(4)',
- run: function () {
- $.summernote.core.range.create(this.$anchor.prev()[0].firstChild, 3, this.$anchor.parent("h1").next("p")[0].firstChild, 30).select();
- simulateClickEvent(this.$anchor.prev()[0], 'mouseup');
- }
-}, {
- content: "open color dropdown",
- trigger: ".note-color button.dropdown-toggle:has(.fa-font)",
-}, {
- content: "change text color again",
- trigger: "button[data-event=foreColor]:visible:eq(3)",
-}, {
- content: "delete selection",
- trigger: '.o_editable.note-editable.o_dirty',
- extra_trigger: '#editable_area > section .row > div:first p font',
- run: "keydown 46", // delete
-}, {
- content: "create an other selection to delete",
- trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1',
- extra_trigger: '.o_editable.note-editable.o_dirty',
- run: function () {
- $.summernote.core.range.createFromNode(this.$anchor.next("p")[0]).clean();
- $.summernote.core.range.create(this.$anchor.find('font:containsExact(ici)')[0].firstChild, 1, this.$anchor.next().next()[0].firstChild, 5).select();
- simulateClickEvent(this.$anchor.find('font:last')[0], 'mouseup');
- },
-}, {
- content: "clean and delete (backspace) an other selection",
- trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1',
- run: "keydown 8", // backspace
-}, {
- content: "an other selection",
- trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) ) h1',
- extra_trigger: '.o_editable.note-editable.o_dirty',
- run: function () {
- $.summernote.core.range.create(this.$anchor.find('font:first')[0].firstChild, 3, this.$anchor.next("p")[0].childNodes[2], 8).select();
- simulateClickEvent(this.$anchor.find('font:first')[0], 'mouseup');
- },
-}, {
- content: "delete an other selection",
- trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) ) h1',
- run: "keydown 46",
-}, {
- content: "undo",
- trigger: '.note-image-popover button[data-event="undo"]',
- extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(Bat) )',
-}, {
- content: "undo again",
- trigger: '.note-air-popover button[data-event="undo"]',
- extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) )',
-}, {
- content: "delete (backspace) after undo",
- trigger: '.o_editable.note-editable.o_dirty',
- extra_trigger: '#editable_area > section .row > div:first:not(:has(p font)) h1',
- run: "keydown 8", // backspace
-}, {
- content: "click on image",
- trigger: '#editable_area > section .row > div:first img[style*="25%"]',
- extra_trigger: '#editable_area > section .row > div:first:has( font:last:containsExact(i) ):has( p:first:containsRegex(/^uam/) )',
-}, {
- content: "Click on resize half",
- trigger: '.note-image-popover:visible button[data-event="resize"][data-value="0.5"]',
-}, {
- content: "Click on edit picture",
- trigger: '.note-image-popover:visible button[data-event="showImageDialog"]',
- extra_trigger: '#editable_area > section .row > div:first img[style*="50%"]',
-}, {
- content: "Click on pictogram tab",
- trigger: 'a[data-toggle="tab"]:contains(Pictogram)',
- extra_trigger: '#editor-media-image',
-}, {
- content: "select a pictogram",
- trigger: '#editor-media-icon.active span.fa:first',
-}, {
- content: "save pictogram",
- trigger: '.modal-footer > .btn-primary',
- extra_trigger: '#editor-media-icon.active span.o_selected',
-}, {
- content: "select a size for the pictogram",
- trigger: '.note-image-popover button[data-event="resizefa"][data-value="3"]',
-}, {
- content: "click on float right",
- trigger: '.note-image-popover:visible button[data-event="floatMe"][data-value="right"]',
- extra_trigger: '#wrapwrap span.fa-3x',
-}, {
- content: "click on create link",
- trigger: '.note-image-popover:visible button[data-event="showLinkDialog"]',
- extra_trigger: '#editable_area > section .row > div:first span.fa.float-right',
-}, {
- content: "insert a link url",
- trigger: 'input[name="url"]',
- run: "text http://www.odoo.com",
-}, {
- content: "click on color style",
- trigger: '.o_link_dialog_color > .o_link_dialog_color_item.btn-success',
- extra_trigger: 'a#link-preview:containsRegex(/^<span [^>]+><\\/span>$/) > span.fa.fa-3x.float-right',
-}, {
- content: "save link",
- trigger: '.modal-footer > .btn-primary',
- extra_trigger: 'a#link-preview.btn.btn-success span.fa.fa-3x.float-right',
-}, {
- content: "click on other picture",
- trigger: '#editable_area > section .row > div:last img',
- extra_trigger: 'body:not(:has(#link-preview)) a.btn[href^="http://"]:has(span.fa.fa-3x.float-right)',
-}, {
- content: "click on create link again",
- trigger: '.note-image-popover:visible button[data-event="showLinkDialog"]',
- extra_trigger: '#editable_area > section .row > div:first span.fa.float-right',
-}, {
- content: "insert an email",
- trigger: 'input[name="url"]',
- run: "text test@test.test",
-}, {
- content: "click on color style again",
- trigger: '.o_link_dialog_color > .o_link_dialog_color_item.btn-success',
- extra_trigger: 'a#link-preview:containsRegex(/^<img [^>]+>$/) img',
-}, {
- content: "save link",
- trigger: '.modal-footer > .btn-primary',
- extra_trigger: 'a#link-preview.btn.btn-success[href="mailto:test@test.test"]:containsRegex(/^<img [^>]+>$/) img',
-}, {
- content: "select for triple enter then double backspace",
- trigger: '#editable_area > section .row > div:first p:eq(2)',
- extra_trigger: 'body:not(:has(#link-preview)) #editable_area > section .row > div:eq(1) > a > img',
- run: function () {
- var p = this.$anchor[0].firstChild;
- $.summernote.core.range.create(p, p.textContent.length, p, p.textContent.length).select();
- simulateClickEvent(p, 'mouseup');
- },
-}, {
- content: "triple enter then double backspace",
- trigger: '#editable_area > section .row > div:first p:eq(2)',
- run: "keydown 66,13,66,13,13,8,8", // B enter B enter enter backspace backspace
-}, {
- content: "add ul content",
- trigger: '#editable_area > section .row > div:first',
- extra_trigger: 'body:not(:has(#editable_area > section .row > div:first p:eq(5), #editable_area > section .row > div:eq(3))) #editable_area > section .row > div:first p:eq(3)',
- run: function () {
- var html = ' <ul> '+
- '\n <li> <p>Batnae municipium. </p></li>'+
- '\n <li> Seleucia praeter.</li>'+
- '\n <li><p>Et hanc quidem.</p></li>'+
- '\n </ul>';
- this.$anchor.append(html);
- var node = this.$anchor.find('ul li p:last')[0].firstChild;
- $.summernote.core.range.create(node, 6).select();
- simulateClickEvent(node, 'mouseup');
- }
-}, {
- content: "click on style dropdown",
- trigger: '.note-air-popover .note-style button.dropdown-toggle',
- extra_trigger: '#editable_area > section .row > div:first ul li p:first',
-}, {
- content: "select h3",
- trigger: '.note-air-popover .note-style ul:visible a[data-value="h3"]',
-}, {
- content: "select h3",
- trigger: '#editable_area > section .row > div:first > ul > li > h3',
- run: function () {
- var node = this.$anchor[0].firstChild;
- $.summernote.core.range.create(node, 0).select();
- simulateClickEvent(node, 'mouseup');
- }
-}, {
- content: "double tabulation",
- trigger: '#editable_area > section .row > div:first > ul > li > h3',
- run: "keydown 9,9", // tabulation
-}, {
- content: "click on order list",
- trigger: '.note-air-popover button[data-event="insertOrderedList"]',
- extra_trigger: '#editable_area > section .row > div:first ul > li > ul > li > ul > li > h3',
-}, {
- content: "select for enter in ul",
- trigger: '#editable_area > section .row > div:first ul li > p:last',
- run: function () {
- this.$anchor[0].firstChild.textContent += "";
- $.summernote.core.range.create(this.$anchor[0].firstChild, 7).select();
- simulateClickEvent(this.$anchor[0], 'mouseup');
- }
-}, {
- content: "enter in ul",
- trigger: '#editable_area > section .row > div:first ul li > p:last',
- run: "keydown 66,13", // enter
-}, {
- trigger: '#editable_area > section .row > div:first ul li > p:eq(1):containsRegex(/^municipium./)',
- content: "backspace in list",
- run: "keydown 8",
-}, {
- content: "end",
- trigger: '#editable_area > section .row > div:first ul li p:eq(0):containsRegex(/^Batnae Bmunicipium.$/)',
-}]);
-
-tour.register('rte_inline', {
- url: '/web_editor/field/html/inline?callback=FieldTextHtml_0&enable_editor=1&datarecord=%7B%7D',
- test: true,
- wait_for: base.ready(),
-}, [{
- content: "Change html for this test",
- trigger: "#editable_area",
- run: function () {
- var html = '\n'+
- '<div>\n'+
- ' <table cellspacing="0" cellpadding="0" width="100%">\n'+
- ' <tbody>\n'+
- ' <tr>\n'+
- ' <td valign="center" width="270">\n'+
- ' <img src="/logo.png" alt="Your Logo" class="rounded-circle img-thumbnail">\n'+
- ' </td>\n'+
- ' <td valign="center" width="270">\n'+
- ' <a href="https://www.facebook.com/Odoo"><span class="fa fa-facebook-square fa-2x text-primary"></span></a>\n'+
- ' <span style="color: rgb(255, 0, 0);" class="fa fa-4x fa-google-plus-square float-right"></span>\n'+
- ' </td>\n'+
- ' </tr>\n'+
- ' </tbody>\n'+
- ' </table>\n'+
- '</div>';
- this.$anchor.html(html);
- }
-}, {
- content: "call clean for save",
- trigger: '#wrapwrap table',
- run: function () {
- core.bus.trigger('snippet_editor_clean_for_save');
- }
-}, {
- content: "check the image style",
- trigger: '#wrapwrap img:first[width][height][style*="-radius"][style*="1px"][style*="padding"]',
-}, {
- content: "check the font image src",
- trigger: '#wrapwrap img:eq(1)[src^="/web_editor/font_to_img/"][src$="/rgb(0,160,157)/28"]',
-}, {
- content: "check the font class to css",
- trigger: '#wrapwrap img:eq(1)[height]:not([class*="fa"])',
-}, {
- content: "check the second font class to css",
- trigger: '#wrapwrap img:eq(2)[style*="float: right"],#wrapwrap img:eq(2)[style*="float:right"]',
-}]);
-});
diff --git a/addons/web_editor/static/src/js/base.js b/addons/web_editor/static/src/js/wysiwyg/fonts.js
index d69cace3100..257ccaf26a3 100644
--- a/addons/web_editor/static/src/js/base.js
+++ b/addons/web_editor/static/src/js/wysiwyg/fonts.js
@@ -1,12 +1,6 @@
-odoo.define('web_editor.base', function (require) {
+odoo.define('wysiwyg.fonts', function (require) {
'use strict';
-var ajax = require('web.ajax');
-var session = require('web.session');
-
-var domReady = $.Deferred();
-$(domReady.resolve.bind(domReady));
-
return {
/**
* Retrieves all the CSS rules which match the given parser (Regex).
@@ -101,57 +95,5 @@ return {
data.alias = _.flatten(_.map(data.cssData, _.property('names')));
});
}),
- /**
- * If a widget needs to be instantiated on page loading, it needs to wait
- * for appropriate resources to be loaded. This function returns a Deferred
- * which is resolved when the dom is ready, the session is bound
- * (translations loaded) and the XML is loaded. This should however not be
- * necessary anymore as widgets should not be parentless and should then be
- * instantiated (directly or not) by the page main component (webclient,
- * website root, editor bar, ...). The DOM will be ready then, the main
- * component is in charge of waiting for the session and the XML can be
- * lazy loaded thanks to the @see Widget.xmlDependencies key.
- *
- * @returns {Deferred}
- */
- ready: function () {
- return $.when(domReady, session.is_bound, ajax.loadXML());
- },
-};
-});
-
-//==============================================================================
-
-odoo.define('web_editor.context', function (require) {
-'use strict';
-
-function getContext(context) {
- var html = document.documentElement;
- return _.extend({
- lang: (html.getAttribute('lang') || 'en_US').replace('-', '_'),
- }, context || {});
-}
-function getExtraContext(context) {
- var html = document.documentElement;
- return _.extend(getContext(), {
- editable: !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python
- translatable: !!html.dataset.translatable,
- edit_translations: !!html.dataset.edit_translations,
- }, context || {});
-}
-
-return {
- get: getContext,
- getExtra: getExtraContext,
};
});
-
-//==============================================================================
-
-odoo.define('web_editor.ready', function (require) {
-'use strict';
-
-var base = require('web_editor.base');
-
-return base.ready();
-});
diff --git a/addons/web_editor/static/src/js/wysiwyg/options.js b/addons/web_editor/static/src/js/wysiwyg/options.js
new file mode 100644
index 00000000000..aeaf8422b72
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/options.js
@@ -0,0 +1,6 @@
+odoo.define('web_editor.wysiwyg.options', function (require) {
+'use strict';
+
+return $.summernote.options;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/abstract.js b/addons/web_editor/static/src/js/wysiwyg/plugin/abstract.js
new file mode 100644
index 00000000000..de776020a42
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/abstract.js
@@ -0,0 +1,222 @@
+odoo.define('web_editor.wysiwyg.plugin.abstract', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+var mixins = require('web.mixins');
+var ServicesMixin = require('web.ServicesMixin');
+var wysiwygTranslation = require('web_editor.wysiwyg.translation');
+var wysiwygOptions = require('web_editor.wysiwyg.options');
+
+//--------------------------------------------------------------------------
+// AbstractPlugin for summernote module API
+//--------------------------------------------------------------------------
+
+var AbstractPlugin = Class.extend(mixins.EventDispatcherMixin, ServicesMixin).extend({
+ /**
+ * Use this prop if you want to extend a summernote plugin.
+ */
+ init: function (context) {
+ var self = this;
+ this._super.apply(this, arguments);
+ this.setParent(context.options.parent);
+ this.context = context;
+
+ if (!this.context.invoke) {
+ // for use outside of wysiwyg/summernote
+ this.context.invoke = function () {};
+ }
+
+ this.$editable = context.layoutInfo.editable;
+ this.editable = this.$editable[0];
+ this.document = this.editable.ownerDocument;
+ this.window = this.document.defaultView;
+ this.summernote = this.window._summernoteSlave || $.summernote; // if the target is in iframe
+ this.ui = this.summernote.ui;
+ this.$editingArea = context.layoutInfo.editingArea;
+ this.options = _.defaults(context.options || {}, wysiwygOptions);
+ this.lang = wysiwygTranslation;
+ this._addButtons();
+ if (this.events) {
+ this.events = _.clone(this.events);
+ _.each(_.keys(this.events), function (key) {
+ var value = self.events[key];
+ if (typeof value === 'string') {
+ value = self[value].bind(self);
+ }
+ if (key.indexOf('summernote.') === 0) {
+ self.events[key] = value;
+ } else {
+ delete self.events[key];
+ key = key.split(' ');
+ if (key.length > 1) {
+ self.context.layoutInfo.editor.on(key[0], key.slice(1).join(' '), value);
+ } else {
+ self.context.layoutInfo.editor.on(key, value);
+ }
+ }
+ });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public summernote module API
+ //--------------------------------------------------------------------------
+
+ shouldInitialize: function () {
+ return true;
+ },
+ initialize: function () {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to add buttons.
+ */
+ _addButtons: function () {},
+ /**
+ * Creates a dropdown button with its contents and behavior.
+ *
+ * @param {str} optionName
+ * @param {str} buttonIcon (ex.: 'note-icon-align')
+ * @param {str} buttonTooltip (ex.: 'Align Paragraph')
+ * @param {Object[]} values (ex.: [{value: 'padding-small', string: 'S'}])
+ * @param {function} onclick
+ */
+ _createDropdownButton: function (optionName, buttonIcon, buttonTooltip, values, onclick) {
+ var self = this;
+
+ if (!onclick) {
+ onclick = function (e) {
+ var classNames = _.map(values, function (item) {
+ return item.value;
+ }).join(' ');
+ var $target = $(self.context.invoke('editor.restoreTarget'));
+ $target.removeClass(classNames);
+ if ($(e.target).data('value')) {
+ $target.addClass($(e.target).data('value'));
+ }
+ };
+ }
+ if (optionName) {
+ this.context.memo('button.' + optionName, function () {
+ return self._renderDropdownButton(buttonIcon, buttonTooltip, values, onclick);
+ });
+ } else {
+ return this._renderDropdownButton(buttonIcon, buttonTooltip, values, onclick);
+ }
+ },
+ /**
+ * Creates a button to toggle a class.
+ *
+ * @param {str} optionName
+ * @param {str} buttonIcon (ex.: 'note-icon-align')
+ * @param {str} buttonTooltip (ex.: 'Align Paragraph')
+ * @param {str} className
+ */
+ _createToggleButton: function (optionName, buttonIcon, buttonTooltip, className) {
+ var self = this;
+ return this._createButton(optionName, buttonIcon, buttonTooltip, function () {
+ var $target = $(self.context.invoke('editor.restoreTarget'));
+ $target.toggleClass(className);
+ });
+ },
+ /**
+ * Creates a button.
+ *
+ * @param {str} optionName
+ * @param {str} buttonIcon (ex.: 'note-icon-align')
+ * @param {str} buttonTooltip (ex.: 'Align Paragraph')
+ * @param {function} onclick
+ */
+ _createButton: function (optionName, buttonIcon, buttonTooltip, onclick) {
+ var self = this;
+ if (optionName) {
+ this.context.memo('button.' + optionName, function () {
+ return self._renderButton(buttonIcon, buttonTooltip, onclick);
+ });
+ } else {
+ return this._renderButton(buttonIcon, buttonTooltip, onclick);
+ }
+ },
+ /**
+ * Helper function to _createButton: renders the button.
+ *
+ * @param {str} buttonIcon (ex.: 'note-icon-align')
+ * @param {str} buttonTooltip (ex.: 'Align Paragraph')
+ * @param {function} onclick
+ * @returns {JQuery}
+ */
+ _renderButton: function (buttonIcon, buttonTooltip, onclick) {
+ var self = this;
+ return this.context.invoke('buttons.button', {
+ contents: buttonIcon.indexOf('<') === -1 ? this.ui.icon(buttonIcon) : buttonIcon,
+ tooltip: buttonTooltip,
+ click: function (e) {
+ e.preventDefault();
+ self.context.invoke('editor.beforeCommand');
+ onclick(e);
+ self.editable.normalize();
+ self.context.invoke('editor.saveRange');
+ self.context.invoke('editor.afterCommand');
+ },
+ }).render();
+ },
+ /**
+ * Helper function to _createDropdownButton: renders the dropdown button.
+ *
+ * @param {str} buttonIcon (ex.: 'note-icon-align')
+ * @param {str} buttonTooltip (ex.: 'Align Paragraph')
+ * @param {Object[]} values (ex.: [{value: 'padding-small', string: 'S'}])
+ * @param {function} onclick
+ * @param {JQuery}
+ */
+ _renderDropdownButton: function (buttonIcon, buttonTooltip, values, onclick) {
+ return this.ui.buttonGroup([
+ this.context.invoke('buttons.button', {
+ className: 'dropdown-toggle',
+ contents: buttonIcon.indexOf('<') === -1 ?
+ this.ui.dropdownButtonContents(this.ui.icon(buttonIcon), this.options) : buttonIcon,
+ tooltip: buttonTooltip,
+ data: {
+ toggle: 'dropdown'
+ }
+ }),
+ this.ui.dropdown({
+ items: values,
+ template: function (item) {
+ return item.string;
+ },
+ click: this._wrapCommand(function (e) {
+ e.preventDefault();
+ onclick(e);
+ }),
+ })
+ ]).render();
+ },
+ /**
+ * Wraps a given function between common actions required
+ * for history (undo/redo) and the maintenance of the DOM/range.
+ *
+ * @param {function} fn
+ * @returns {any} the return of fn
+ */
+ _wrapCommand: function (fn) {
+ var self = this;
+ return function () {
+ self.context.invoke('editor.restoreRange');
+ self.context.invoke('editor.beforeCommand');
+ var res = fn.apply(self, arguments);
+ self.editable.normalize();
+ self.context.invoke('editor.saveRange');
+ self.context.invoke('editor.afterCommand');
+ return res;
+ };
+ },
+
+});
+
+return AbstractPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js b/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js
new file mode 100644
index 00000000000..506646d4697
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/bullet.js
@@ -0,0 +1,565 @@
+odoo.define('web_editor.wysiwyg.plugin.bullet', function (require) {
+'use strict';
+
+var core = require('web.core');
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var wysiwygOptions = require('web_editor.wysiwyg.options');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+var wysiwygTranslation = require('web_editor.wysiwyg.translation');
+
+var _t = core._t;
+var dom = $.summernote.dom;
+
+wysiwygOptions.icons.checklist = 'fa fa-check-square';
+wysiwygOptions.keyMap.pc['CTRL+SHIFT+NUM9'] = 'insertCheckList';
+wysiwygOptions.keyMap.mac['CMD+SHIFT+NUM9'] = 'insertCheckList';
+wysiwygTranslation.lists.checklist = _t('Checklist');
+wysiwygTranslation.help.checklist = _t('Toggle checkbox list');
+
+
+var BulletPlugin = AbstractPlugin.extend({
+ events: {
+ 'summernote.mousedown': '_onMouseDown',
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Insert an ordered list, an unordered list or a checklist.
+ * If already in list, remove the list or convert it to the given type.
+ *
+ * @param {string('ol'|'ul'|'checklist')} type the type of list to insert
+ * @returns {false|Node[]} contents of the ul/ol or content of the converted/removed list
+ */
+ insertList: function (type) {
+ var range = this.context.invoke('editor.createRange');
+ if (!range) {
+ return;
+ }
+ var res;
+ var start = range.getStartPoint();
+ var end = range.getEndPoint();
+
+ var isInList = dom.ancestor(range.sc, dom.isList);
+ if (isInList) {
+ res = this._convertList(false, [], start, end, type);
+ } else {
+ var ul = this._createList(type);
+ res = [].slice.call(ul.children);
+ }
+
+ var startLeaf = this.context.invoke('HelperPlugin.firstLeaf', start.node);
+ var endLeaf = this.context.invoke('HelperPlugin.firstLeaf', end.node);
+ range = this.context.invoke('editor.setRange', startLeaf, start.offset, endLeaf, end.offset);
+ range.select();
+ this.context.invoke('editor.saveRange');
+
+ return res;
+ },
+ /**
+ * Indent a node (list or format node).
+ *
+ * @returns {false|Node[]} contents of list/indented item
+ */
+ indent: function () {
+ return this._indent();
+ },
+ /**
+ * Outdent a node (list or format node).
+ *
+ * @returns {false|Node[]} contents of list/outdented item
+ */
+ outdent: function () {
+ return this._indent(true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add checklist buttons.
+ */
+ _addButtons: function () {
+ var self = this;
+ this._super();
+ this.context.memo('help.checklist', this.lang.help.checklist);
+ this._createButton('checklist', this.options.icons.checklist, this.lang.lists.checklist, function (e) {
+ e.preventDefault();
+ self.context.invoke('editor.insertCheckList');
+ });
+ },
+ /**
+ * Convert ul<->ol or remove ul/ol.
+ *
+ * @param {boolean} isWithinElem true if selection already inside the LI
+ * @param {DOM[]} nodes selected nodes
+ * @param {Object} startPoint
+ * @param {Object} endPoint
+ * @param {boolean} sorted
+ * @returns {boolean} isWithinElem
+ */
+ _convertList: function (isWithinElem, nodes, startPoint, endPoint, sorted) {
+ var ol = dom.ancestor(startPoint.node, dom.isList);
+ var parent = ol.parentNode;
+
+ // get selected lis
+
+ var lis = [];
+ var lisBefore = [];
+ var lisAfter = [];
+ _.each(ol.children, function (li) {
+ if (!isWithinElem && (li === startPoint.node || $.contains(li, startPoint.node))) {
+ isWithinElem = true;
+ }
+ if (isWithinElem) {
+ lis.push(li);
+ } else if (lis.length) {
+ lisAfter.push(li);
+ } else {
+ lisBefore.push(li);
+ }
+ if (isWithinElem && (li === endPoint.node || $.contains(li, endPoint.node))) {
+ isWithinElem = false;
+ }
+ });
+
+ var res = lis;
+
+ if (lisBefore.length) {
+ var ulBefore = this.document.createElement(ol.tagName);
+ ulBefore.className = ol.className;
+
+ if (dom.isLi(ol.parentNode)) {
+ var li = this.document.createElement('li');
+ li.className = ol.parentNode.className;
+ $(li).insertBefore(ol.parentNode);
+ li.appendChild(ulBefore);
+ } else {
+ $(ulBefore).insertBefore(ol);
+ }
+
+ $(ulBefore).append(lisBefore);
+ }
+ if (lisAfter.length) {
+ var ulAfter = this.document.createElement(ol.tagName);
+ ulAfter.className = ol.className;
+
+ if (dom.isLi(ol.parentNode)) {
+ var li = this.document.createElement('li');
+ li.className = ol.parentNode.className;
+ $(li).insertAfter(ol.parentNode);
+ li.appendChild(ulAfter);
+ } else {
+ $(ulAfter).insertAfter(ol);
+ }
+
+ $(ulAfter).append(lisAfter);
+ }
+
+ // convert ul<->ol or remove list
+ var current = ol.tagName === 'UL' && ol.className.indexOf('o_checklist') !== -1 ? 'checklist' : ol.tagName.toLowerCase();
+ if (current !== sorted) {
+ // convert ul <-> ol
+
+ var ul;
+ if (sorted === 'checklist' && current === "ul") {
+ ul = ol;
+ } else if (sorted === 'ul' && current === 'checklist') {
+ $(ol).removeClass('o_checklist');
+ ul = ol;
+ } else {
+ ul = this.document.createElement(sorted === "ol" ? "ol" : "ul");
+ ul.className = ol.className;
+ $(ul).insertBefore(ol).append(lis);
+ parent.removeChild(ol);
+ }
+ if (sorted === 'checklist') {
+ $(ul).addClass('o_checklist');
+ }
+
+ this.context.invoke('HelperPlugin.deleteEdge', ul, 'next');
+ this.context.invoke('HelperPlugin.deleteEdge', ul, 'prev');
+
+ } else {
+ // remove ol/ul
+
+ if (dom.isLi(parent) || dom.isList(parent)) {
+ if (dom.isLi(parent)) {
+ ol = parent;
+ parent = ol.parentNode;
+ }
+ $(lis).insertBefore(ol);
+ } else {
+ res = [];
+ _.each(lis, function (li) {
+ res.push.apply(res, li.childNodes);
+ $(li.childNodes).insertBefore(ol);
+ });
+
+ // wrap in p
+
+ var hasNode = _.find(res, function (node) {
+ return node.tagName && node.tagName !== "BR" && !dom.isMedia(node);
+ });
+ if (!hasNode) {
+ var p = this.document.createElement('p');
+ $(p).insertBefore(ol).append(res);
+ res = [p];
+ }
+ }
+ parent.removeChild(ol);
+
+ }
+
+ nodes.push.apply(nodes, res);
+
+ return isWithinElem;
+ },
+ /**
+ * Create a list if allowed.
+ *
+ * @param {string('ol'|'ul'|'checklist')} type the type of list to insert
+ * @returns {false|Node} the list, if any
+ */
+ _createList: function (type) {
+ var nodes = this.context.invoke('HelperPlugin.getSelectedNodes');
+ var formatNodes = this._filterEditableFormatNodes(nodes);
+ if (!formatNodes.length) {
+ return;
+ }
+
+ var ul = this._createListElement(type);
+ $(formatNodes[0][0] || formatNodes[0]).before(ul);
+ this._fillListElementWith(ul, formatNodes);
+ this._deleteListElementEdges(ul);
+
+ return ul;
+ },
+ /**
+ * Create a list element of the given type and return it.
+ *
+ * @param {string('ol'|'ul'|'checklist')} type the type of list to insert
+ * @returns {Node}
+ */
+ _createListElement: function (type) {
+ var ul = this.document.createElement(type === "ol" ? "ol" : "ul");
+ if (type === 'checklist') {
+ ul.className = 'o_checklist';
+ }
+ return ul;
+ },
+ /**
+ * Delete a list element's edges if necessary.
+ *
+ * @param {Node} ul
+ */
+ _deleteListElementEdges: function (ul) {
+ this.context.invoke('HelperPlugin.deleteEdge', ul, 'next');
+ this.context.invoke('HelperPlugin.deleteEdge', ul, 'prev');
+ this.editable.normalize();
+ },
+ /**
+ * Fill a list element with the nodes passed, wrapped in LIs.
+ *
+ * @param {Node} ul
+ * @param {Node[]} nodes
+ */
+ _fillListElementWith: function (ul, nodes) {
+ var self = this;
+ _.each(nodes, function (node) {
+ var li = self.document.createElement('li');
+ $(li).append(node);
+ ul.appendChild(li);
+ });
+ },
+ /**
+ * Filter the editable format ancestors of the given nodes
+ * and fill or wrap them if needed for range selection.
+ *
+ * @param {Node[]} nodes
+ * @returns {Node[]}
+ */
+ _filterEditableFormatNodes: function (nodes) {
+ var self = this;
+ var formatNodes = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes);
+ formatNodes = _.compact(_.map(formatNodes, function (node) {
+ var ancestor = (!node.tagName || node.tagName === 'BR') && dom.ancestor(node, dom.isCell);
+ if (ancestor && self.options.isEditableNode(ancestor)) {
+ if (!ancestor.childNodes.length) {
+ var br = self.document.createElement('br');
+ ancestor.appendChild(br);
+ }
+ var p = self.document.createElement('p');
+ $(p).append(ancestor.childNodes);
+ ancestor.appendChild(p);
+ return p;
+ }
+ return self.options.isEditableNode(node) && node || null;
+ }));
+ return formatNodes;
+ },
+ /**
+ * Indent or outdent a format node.
+ *
+ * @param {bool} outdent true to outdent, false to indent
+ * @returns {false|[]Node} indented nodes
+ */
+ _indent: function (outdent) {
+ var range = this.context.invoke('editor.createRange');
+ if (!range) {
+ return;
+ }
+
+ var self = this;
+ var nodes = [];
+ var isWithinElem;
+ var ancestor = range.commonAncestor();
+ var $dom = $(ancestor);
+
+ if (!dom.isList(ancestor)) {
+ // to indent a selection, we indent the child nodes of the common
+ // ancestor that contains this selection
+ $dom = $(ancestor.tagName ? ancestor : ancestor.parentNode).children();
+ }
+
+ // if selection is inside indented contents and outdent is true, we can outdent this node
+ var indentedContent = outdent && dom.ancestor(ancestor, function (node) {
+ var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft';
+ return node.tagName && !!parseFloat(node.style[style] || 0);
+ });
+
+ if (indentedContent) {
+ $dom = $(indentedContent);
+ } else {
+ // if selection is inside a list, we indent its list items
+ $dom = $(dom.ancestor(ancestor, dom.isList));
+ if (!$dom.length) {
+ // if the selection is contained in a single HTML node, we indent
+ // the first ancestor 'content block' (P, H1, PRE, ...) or TD
+ $dom = $(range.sc).closest(this.options.styleTags.join(',') + ',td');
+ }
+ }
+
+ // if select tr, take the first td
+ $dom = $dom.map(function () {
+ return this.tagName === "TR" ? this.firstElementChild : this;
+ });
+
+ $dom.each(function () {
+ if (isWithinElem || $.contains(this, range.sc)) {
+ if (dom.isList(this)) {
+ if (outdent) {
+ var type = this.tagName === 'OL' ? 'ol' : (this.className && this.className.indexOf('o_checklist') !== -1 ? 'checklist' : 'ul');
+ isWithinElem = self._convertList(isWithinElem, nodes, range.getStartPoint(), range.getEndPoint(), type);
+ } else {
+ isWithinElem = self._indentUL(isWithinElem, nodes, this, range.sc, range.ec);
+ }
+ } else if (self.context.invoke('HelperPlugin.isFormatNode', this) || dom.ancestor(this, dom.isCell)) {
+ isWithinElem = self._indentFormatNode(outdent, isWithinElem, nodes, this, range.sc, range.ec);
+ }
+ }
+ });
+
+ if ($dom.parent().length) {
+ var $parent = $dom.parent();
+
+ // remove text nodes between lists
+ var $ul = $parent.find('ul, ol');
+ if (!$ul.length) {
+ $ul = $(dom.ancestor(range.sc, dom.isList));
+ }
+ $ul.each(function () {
+ var notWhitespace = /\S/;
+ if (
+ this.previousSibling &&
+ this.previousSibling !== this.previousElementSibling &&
+ !this.previousSibling.textContent.match(notWhitespace)
+ ) {
+ this.parentNode.removeChild(this.previousSibling);
+ }
+ if (
+ this.nextSibling &&
+ this.nextSibling !== this.nextElementSibling &&
+ !this.nextSibling.textContent.match(notWhitespace)
+ ) {
+ this.parentNode.removeChild(this.nextSibling);
+ }
+ });
+
+ // merge same ul or ol
+ $ul.prev('ul, ol').each(function () {
+ self.context.invoke('HelperPlugin.deleteEdge', this, 'next');
+ });
+
+ }
+
+ range.normalize().select();
+ this.context.invoke('editor.saveRange');
+
+ return !!nodes.length && nodes;
+ },
+ /**
+ * Indent several LIs in a list.
+ *
+ * @param {bool} isWithinElem true if selection already inside the LI
+ * @param {Node[]} nodes
+ * @param {Node} UL
+ * @param {Node} start
+ * @param {Node} end
+ * @returns {bool} isWithinElem
+ */
+ _indentUL: function (isWithinElem, nodes, UL, start, end) {
+ var next;
+ var tagName = UL.tagName;
+ var node = UL.firstChild;
+ var ul = document.createElement(tagName);
+ ul.className = UL.className;
+ var flag;
+
+ if (isWithinElem) {
+ flag = true;
+ }
+
+ // create and fill ul into a li
+ while (node) {
+ if (flag || node === start || $.contains(node, start)) {
+ isWithinElem = true;
+ node.parentNode.insertBefore(ul, node);
+ }
+ next = node.nextElementSibling;
+ if (isWithinElem) {
+ ul.appendChild(node);
+ nodes.push(node);
+ }
+ if (node === end || $.contains(node, end)) {
+ isWithinElem = false;
+ break;
+ }
+ node = next;
+ }
+
+ var temp;
+ var prev = ul.previousElementSibling;
+ if (
+ prev && prev.tagName === "LI" &&
+ (temp = prev.firstElementChild) && temp.tagName === tagName &&
+ ((prev.firstElementChild || prev.firstChild) !== ul)
+ ) {
+ $(prev.firstElementChild || prev.firstChild).append($(ul).contents());
+ $(ul).remove();
+ ul = prev;
+ ul.parentNode.removeChild(ul.nextElementSibling);
+ }
+ next = ul.nextElementSibling;
+ if (
+ next && next.tagName === "LI" &&
+ (temp = next.firstElementChild) && temp.tagName === tagName &&
+ (ul.firstElementChild !== next.firstElementChild)
+ ) {
+ $(ul.firstElementChild).append($(next.firstElementChild).contents());
+ $(next.firstElementChild).remove();
+ ul.parentNode.removeChild(ul.nextElementSibling);
+ }
+
+ // wrap in li
+ var li = this.document.createElement('li');
+ li.className = 'o_indent';
+ $(ul).before(li);
+ li.appendChild(ul);
+
+ return isWithinElem;
+ },
+ /**
+ * Outdent a container node.
+ *
+ * @param {Node} node
+ * @returns {float} margin
+ */
+ _outdentContainer: function (node) {
+ var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft';
+ var margin = parseFloat(node.style[style] || 0) - 1.5;
+ node.style[style] = margin > 0 ? margin + "em" : "";
+ return margin;
+ },
+ /**
+ * Indent a container node.
+ *
+ * @param {Node} node
+ * @returns {float} margin
+ */
+ _indentContainer: function (node) {
+ var style = dom.isCell(node) ? 'paddingLeft' : 'marginLeft';
+ var margin = parseFloat(node.style[style] || 0) + 1.5;
+ node.style[style] = margin + "em";
+ return margin;
+ },
+ /**
+ * Indent/outdent a format node.
+ *
+ * @param {bool} outdent true to outdent, false to indent
+ * @param {bool} isWithinElem true if selection already inside the element
+ * @param {DOM[]} nodes
+ * @param {DOM} p
+ * @param {DOM} start
+ * @param {DOM} end
+ * @returns {bool} isWithinElem
+ */
+ _indentFormatNode: function (outdent, isWithinElem, nodes, p, start, end) {
+ if (p === start || $.contains(p, start) || $.contains(start, p)) {
+ isWithinElem = true;
+ }
+ if (isWithinElem) {
+ nodes.push(p);
+ if (outdent) {
+ this._outdentContainer(p);
+ } else {
+ this._indentContainer(p);
+ }
+ }
+ if (p === end || $.contains(p, end) || $.contains(end, p)) {
+ isWithinElem = false;
+ }
+ return isWithinElem;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handle
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {SummernoteEvent} se
+ * @param {jQueryEvent} e
+ */
+ _onMouseDown: function (se, e) {
+ if (!dom.isLi(e.target) || !$(e.target).parent('ul.o_checklist').length || e.offsetX > 0) {
+ return;
+ }
+ e.preventDefault();
+ var checked = $(e.target).hasClass('o_checked');
+ $(e.target).toggleClass('o_checked', !checked);
+ var $sublevel = $(e.target).next('ul.o_checklist, li:has(> ul.o_checklist)').find('> li, ul.o_checklist > li');
+ var $parents = $(e.target).parents('ul.o_checklist').map(function () {
+ return this.parentNode.tagName === 'LI' ? this.parentNode : this;
+ });
+ if (checked) {
+ $sublevel.removeClass('o_checked');
+ $parents.prev('ul.o_checklist li').removeClass('o_checked');
+ } else {
+ $sublevel.addClass('o_checked');
+ var $lis;
+ do {
+ $lis = $parents.not(':has(li:not(.o_checked))').prev('ul.o_checklist li:not(.o_checked)');
+ $lis.addClass('o_checked');
+ } while ($lis.length);
+ }
+ },
+});
+
+registry.add('BulletPlugin', BulletPlugin);
+
+return BulletPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/buttons.js b/addons/web_editor/static/src/js/wysiwyg/plugin/buttons.js
new file mode 100644
index 00000000000..7059c448d81
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/buttons.js
@@ -0,0 +1,88 @@
+odoo.define('web_editor.wysiwyg.plugin.buttons', function (require) {
+'use strict';
+
+var Plugins = require('web_editor.wysiwyg.plugins');
+
+var dom = $.summernote.dom;
+
+Plugins.buttons.include({
+ /**
+ * Fix tooltip for 'option' buttons (summernote 0.8.9 bug)
+ * Remove this once the library is updated to 0.8.10.
+ *
+ * @override
+ */
+ addToolbarButtons: function () {
+ this.options.codeview = this.lang.options.codeview;
+ this.options.help = this.lang.options.help;
+ this.options.fullscreen = this.lang.options.fullscreen;
+ this._super();
+ },
+ /**
+ * Show current style (of start of selection) in magic wand dropdown.
+ *
+ * @param {JQuery Object} $container
+ */
+ updateActiveStyleDropdown: function ($container) {
+ var self = this;
+ var range = this.context.invoke('editor.createRange');
+ var el = dom.ancestor(range.sc, function (n) {
+ return n.tagName && self.options.styleTags.indexOf(n.tagName.toLowerCase()) !== -1;
+ });
+ if (el) {
+ var tagName = el.tagName.toLowerCase();
+ $container.find('.dropdown-style a').each(function (idx, item) {
+ var $item = $(item);
+ // always compare string to avoid creating another func.
+ var isChecked = ($item.data('value') + '') === (tagName + '');
+ $item.toggleClass('active', isChecked);
+ });
+ } else {
+ var $item = $container.find('.dropdown-style a.active');
+ $item.removeClass('active');
+ }
+ },
+ /**
+ * @override
+ */
+ updateCurrentStyle: function ($container) {
+ this._super.apply(this, arguments);
+
+ this.updateParaAlignIcon($container || this.$toolbar);
+ this.updateActiveStyleDropdown($container || this.$toolbar);
+ },
+ /**
+ * Show current paragraph alignment (of start of selection) on paragraph alignment dropdown.
+ *
+ * @param {JQuery Object} $container
+ */
+ updateParaAlignIcon: function ($container) {
+ var range = this.context.invoke('editor.createRange');
+ var $paraIcon = $container.find('.note-para .dropdown-toggle i');
+ var el = dom.isText(range.sc) ? range.sc.parentNode : range.sc;
+ if (el) {
+ $paraIcon.removeClass();
+ switch ($(el).css('text-align')) {
+ case 'left':
+ $paraIcon.addClass('note-icon-align-left');
+ break;
+ case 'center':
+ $paraIcon.addClass('note-icon-align-center');
+ break;
+ case 'right':
+ $paraIcon.addClass('note-icon-align-right');
+ break;
+ case 'justify':
+ $paraIcon.addClass('note-icon-align-justify');
+ break;
+ default:
+ $paraIcon.addClass('note-icon-align-left');
+ break;
+ }
+ }
+ },
+});
+
+return Plugins.buttons;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/codeview.js b/addons/web_editor/static/src/js/wysiwyg/plugin/codeview.js
new file mode 100644
index 00000000000..01ffc52b205
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/codeview.js
@@ -0,0 +1,60 @@
+odoo.define('web_editor.wysiwyg.plugin.codeview', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var _t = core._t;
+
+
+var CodeviewPlugin = Plugins.codeview.extend({
+ /**
+ * @override
+ */
+ activate: function () {
+ this._super();
+ if (this.$codable.height() === 0) {
+ this.$codable.height(180);
+ }
+ this.context.invoke('editor.hidePopover');
+ this.context.invoke('editor.clearTarget');
+ },
+ /**
+ * @override
+ */
+ deactivate: function () {
+ if (
+ this.context.invoke('HelperPlugin.hasJinja', this.context.invoke('code')) &&
+ !this.isBeingDestroyed
+ ) {
+ var message = _t("Your code contains JINJA conditions.\nRemove them to return to WYSIWYG HTML edition.");
+ this.do_warn(_t("Cannot edit HTML"), message);
+ this.$codable.focus();
+ return;
+ }
+ this._super();
+ this.$editable.css('height', '');
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.isBeingDestroyed = true;
+ this._super();
+ },
+ /**
+ * Force activation of the code view.
+ */
+ forceActivate: function () {
+ if (!this.isActivated()) {
+ this.activate();
+ }
+ },
+});
+
+registry.add('codeview', CodeviewPlugin);
+
+return CodeviewPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js b/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js
new file mode 100644
index 00000000000..8727a6aa8a1
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/dropzone.js
@@ -0,0 +1,210 @@
+odoo.define('web_editor.wysiwyg.plugin.dropzone', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var _t = core._t;
+var dom = $.summernote.dom;
+
+var DropzonePlugin = Plugins.dropzone.extend({
+ //--------------------------------------------------------------------------
+ // Public summernote module API
+ //--------------------------------------------------------------------------
+
+ /**
+ * Disable Summernote's handling of drop events.
+ */
+ attachDragAndDropEvent: function () {
+ this._super.apply(this, arguments);
+ this.$dropzone.off('drop');
+ this.$dropzone.on('drop', this._onDrop.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clean up then drops HTML or plain text into the editor.
+ *
+ * @private
+ * @param {String} html
+ * @param {Boolean} textOnly true to allow only dropping plain text
+ */
+ _dropHTML: function (html, textOnly) {
+ this.context.invoke('editor.beforeCommand');
+
+ // Clean up
+ var nodes = this.context.invoke('TextPlugin.prepareClipboardData', html);
+
+ // Delete selection
+ this.context.invoke('HelperPlugin.deleteSelection');
+
+ // Insert the nodes
+ this.context.invoke('TextPlugin.pasteNodes', nodes, textOnly);
+ this.context.invoke('HelperPlugin.normalize');
+ this.context.invoke('editor.saveRange');
+
+ this.context.invoke('editor.afterCommand');
+ },
+ /**
+ * Drop images into the editor: save them as attachments.
+ *
+ * @private
+ * @param {File[]]} files (images only)
+ */
+ _dropImages: function (files) {
+ var self = this;
+ this.context.invoke('editor.beforeCommand');
+ var range = this.context.invoke('editor.createRange');
+
+ var spinners = [];
+ var images = [];
+ var defs = [];
+ _.each(files, function (file) {
+ // Add spinner
+ var spinner = $('<span class="fa fa-spinner fa-spin">').attr('data-filename', file.name)[0];
+ self.context.invoke('editor.hidePopover');
+ if (range.sc.tagName) {
+ if (range.so >= dom.nodeLength(range.sc)) {
+ $(range.sc).append(spinner);
+ } else {
+ $(range.sc).before(range.sc.childNodes[range.so]);
+ }
+ } else {
+ range.sc.splitText(range.so);
+ $(range.sc).after(spinner);
+ }
+ spinners.push(spinner);
+
+ // save images as attachments
+ var def = $.Deferred();
+ defs.push(def);
+ // Get image's Base64 string
+ var reader = new FileReader();
+ reader.addEventListener('load', function (e) {
+ self._uploadImage(e.target.result, file.name).then(function (attachment) {
+ // Make the HTML
+ var image = self.document.createElement('img');
+ image.setAttribute('style', 'width: 100%;');
+ image.src = '/web/content/' + attachment.id + '/' + attachment.name;
+ image.alt = attachment.name;
+ $(spinner).replaceWith(image);
+ images.push(image);
+ def.resolve(image);
+ $(image).trigger('dropped');
+ });
+ });
+ reader.readAsDataURL(file);
+ });
+
+ this.trigger_up('drop_images', {
+ spinners: spinners,
+ promises: defs,
+ });
+
+ $.when.apply($, defs).then(function () {
+ var defs = [];
+ $(images).each(function () {
+ if (!this.height) {
+ var def = $.Deferred();
+ defs.push(def);
+ $(this).one('load error abort', def.resolve.bind(def));
+ }
+ });
+ $.when.apply($, defs).then(function () {
+ if (images.length === 1) {
+ range = self.context.invoke('editor.setRange', _.last(images), 0);
+ range.select();
+ self.context.invoke('editor.saveRange');
+ self.context.invoke('editor.afterCommand');
+ self.context.invoke('MediaPlugin.updatePopoverAfterEdit', images[0]);
+ } else {
+ self.context.invoke('editor.afterCommand');
+ }
+ });
+ });
+ },
+ /**
+ * Upload an image from its Base64 representation.
+ *
+ * @private
+ * @param {String} imageBase64
+ * @param {String} fileName
+ * @returns {Promise}
+ */
+ _uploadImage: function (imageBase64, fileName) {
+ var options = {};
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ type: 'media',
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ return this._rpc({
+ route: '/web_editor/add_image_base64',
+ params: {
+ res_model: options.res_model,
+ res_id: options.res_id,
+ image_base64: imageBase64.split(';base64,').pop(),
+ filename: fileName,
+ },
+ });
+ },
+ /**
+ * @private
+ * @param {JQueryEvent} e
+ */
+ _onDrop: function (e) {
+ e.preventDefault();
+
+ if (this.options.disableDragAndDrop) {
+ return;
+ }
+ var dataTransfer = e.originalEvent.dataTransfer;
+
+ if (!this._canDropHere()) {
+ this.context.invoke('HelperPlugin.notify', _t("Not a dropzone"), _t("Dropping is prohibited in this area."));
+ return;
+ }
+
+ if (dataTransfer.getData('text/html')) {
+ this._dropHTML(dataTransfer.getData('text/html'));
+ return;
+ }
+ if (dataTransfer.files.length) {
+ var images = [];
+ _.each(dataTransfer.files, function (file) {
+ if (file.type.indexOf('image') !== -1) {
+ images.push(file);
+ }
+ });
+ if (!images.length || images.length < dataTransfer.files.length) {
+ this.context.invoke('HelperPlugin.notify', _t("Unsupported file type"), _t("Images are the only file types that can be dropped."));
+ }
+ if (images.length) {
+ this._dropImages(images);
+ }
+ }
+ },
+ /**
+ * Return true if dropping is allowed at the current range.
+ *
+ * @private
+ * @returns {Boolean}
+ */
+ _canDropHere: function () {
+ var range = this.context.invoke('editor.createRange');
+ return this.options.isEditableNode(range.sc);
+ },
+});
+
+registry.add('dropzone', DropzonePlugin);
+
+return DropzonePlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/editor.js b/addons/web_editor/static/src/js/wysiwyg/plugin/editor.js
new file mode 100644
index 00000000000..350a9158188
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/editor.js
@@ -0,0 +1,189 @@
+odoo.define('web_editor.wysiwyg.plugin.editor', function (require) {
+'use strict';
+
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+var TablePlugin = require('web_editor.wysiwyg.plugin.table');
+
+
+var NewSummernoteEditor = Plugins.editor.extend({
+ //--------------------------------------------------------------------------
+ // Public summernote module API
+ //--------------------------------------------------------------------------
+
+ initialize: function () {
+ var self = this;
+ this.history = this.context.modules.HistoryPlugin;
+ this.dropzone = this.context.modules.DropzonePlugin;
+
+ this.insertTable = this.wrapCommand(this._insertTable.bind(this));
+ this.insertOrderedList = this.wrapCommand(this._insertOrderedList.bind(this));
+ this.insertUnorderedList = this.wrapCommand(this._insertUnorderedList.bind(this));
+ this.insertCheckList = this.wrapCommand(this._insertCheckList.bind(this));
+ this.indent = this.wrapCommand(this._indent.bind(this));
+ this.outdent = this.wrapCommand(this._outdent.bind(this));
+ this.table = new TablePlugin(this.context);
+ this.hasFocus = this._hasFocus.bind(this);
+
+ this._makeTextCommand('formatBlock');
+ this._makeTextCommand('removeFormat');
+ _.each('bold,italic,underline,strikethrough,superscript,subscript'.split(','), function (sCmd) {
+ self._makeTextCommand('formatText', sCmd);
+ });
+ _.each('justifyLeft,justifyCenter,justifyRight,justifyFull'.split(','), function (sCmd) {
+ self._makeTextCommand('formatBlockStyle', sCmd);
+ });
+
+ this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Hide all popovers.
+ */
+ hidePopover: function () {
+ this.context.invoke('MediaPlugin.hidePopovers');
+ this.context.invoke('LinkPopover.hide');
+ },
+ /*
+ * Focus the editor.
+ */
+ focus: function () {
+ // [workaround] Screen will move when page is scolled in IE.
+ // - do focus when not focused
+ if (!this.hasFocus()) {
+ var range = $.summernote.range.create();
+ if (range) {
+ $(range.sc).closest('[contenteditable]').focus();
+ range.select();
+ } else {
+ this.$editable.focus();
+ }
+ }
+ },
+ /**
+ * Fix double-undo (CTRL-Z) issue with Odoo integration.
+ *
+ * @override
+ */
+ undo: function () {
+ this.createRange();
+ this._super();
+ },
+ /**
+ * Set the range at the given nodes and offsets.
+ * If no `ec` is specified, the range is collapsed on start.
+ * Note: Does NOT select the range.
+ *
+ * @param {Node} sc
+ * @param {Number} so
+ * @param {Node} [ec]
+ * @param {Number} [eo]
+ * @returns {Object} range
+ */
+ setRange: function (sc, so, ec, eo) {
+ var range = this.createRange();
+ range.sc = sc;
+ range.so = so;
+ range.ec = ec || sc;
+ range.eo = eo || so;
+ return range;
+ },
+ /**
+ * Remove a link and preserve its text contents.
+ */
+ unlink: function () {
+ this.beforeCommand();
+ this.context.invoke('LinkPlugin.unlink');
+ this.afterCommand();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns true if editable area has focus.
+ *
+ * @private
+ * @returns {Boolean}
+ */
+ _hasFocus: function () {
+ return this.$editable.is(':focus') || !!this.$editable.find('[contenteditable]:focus').length;
+ },
+ /**
+ * Indent a list or a format node.
+ *
+ * @private
+ * @returns {false|Node[]} contents of list/indented item
+ */
+ _indent: function () {
+ return this.context.invoke('BulletPlugin.indent');
+ },
+ /**
+ * Insert a checklist.
+ *
+ * @private
+ */
+ _insertCheckList: function () {
+ this.context.invoke('BulletPlugin.insertList', 'checklist');
+ },
+ /**
+ * Insert an ordered list.
+ *
+ * @private
+ */
+ _insertOrderedList: function () {
+ this.context.invoke('BulletPlugin.insertList', 'ol');
+ },
+ /**
+ * Insert table (respecting unbreakable node rules).
+ *
+ * @private
+ * @param {string} dim (eg: 3x3)
+ */
+ _insertTable: function (dim) {
+ this.context.invoke('TablePlugin.insertTable', dim);
+ },
+ /**
+ * Insert an ordered list.
+ *
+ * @private
+ */
+ _insertUnorderedList: function () {
+ this.context.invoke('BulletPlugin.insertList', 'ul');
+ },
+ /**
+ * Adds a TextPlugin command to the editor object.
+ * The command will exist in the editor object under the name
+ * arg if specified, or commandName otherwise.
+ *
+ * @param {String} commandName
+ * @param {String} arg
+ */
+ _makeTextCommand: function (commandName, arg) {
+ var self = this;
+ this[arg || commandName] = self.wrapCommand(function (value) {
+ self.context.invoke('TextPlugin.' + commandName, arg || value, value);
+ });
+ },
+ /**
+ * Outdent a list or a format node.
+ *
+ * @private
+ * @returns {false|Node[]} contents of list/outdented item
+ */
+ _outdent: function () {
+ return this.context.invoke('BulletPlugin.outdent');
+ },
+});
+
+// Override Summernote default editor
+registry.add('editor', NewSummernoteEditor);
+
+return NewSummernoteEditor;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/font.js b/addons/web_editor/static/src/js/wysiwyg/plugin/font.js
new file mode 100644
index 00000000000..91ac93470ba
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/font.js
@@ -0,0 +1,559 @@
+odoo.define('web_editor.wysiwyg.plugin.font', function (require) {
+'use strict';
+
+var core = require('web.core');
+var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog');
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+var wysiwygTranslation = require('web_editor.wysiwyg.translation');
+var wysiwygOptions = require('web_editor.wysiwyg.options');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var dom = $.summernote.dom;
+
+//--------------------------------------------------------------------------
+// Font (colorpicker & font-size)
+//--------------------------------------------------------------------------
+
+dom.isFont = function (node) {
+ return node && node.tagName === "FONT" || dom.isIcon(node);
+};
+
+var FontPlugin = AbstractPlugin.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates both ColorPicker buttons (fore- and backcolors) containing their palettes.
+ *
+ * @returns {jQuery} the buttons' div.btn-group container
+ */
+ createColorPickerButtons: function () {
+ var self = this;
+ var $container = $('<div class="note-btn-group btn-group note-color"/>');
+
+ // create and append the buttons
+
+ this._createForeColorButton().appendTo($container);
+ this._createBackColorButton().appendTo($container);
+
+ // add event
+
+ $container.on('mousedown', function () {
+ self.context.invoke('editor.saveRange');
+ });
+ return $container;
+ },
+ /**
+ * Takes the font size button created by summernote and override its
+ * events so as to customize its behavior.
+ *
+ * @param {jQuery} $button the button, as created by summernote
+ * @returns {jQuery} the overridden button
+ */
+ overrideFontSizeButton: function ($button) {
+ var self = this;
+ $button.click(function (e) {
+ e.preventDefault();
+ });
+ $button.find('.dropdown-menu').off('click').on('mousedown', function (e) {
+ e.preventDefault();
+ self.context.invoke('editor.createRange');
+ self.context.invoke('editor.beforeCommand');
+ var $target = $(e.target);
+ self.context.invoke('FontPlugin.changeFontSize', $target.closest('[data-value]').data('value'), $target);
+ self.context.invoke('buttons.updateCurrentStyle');
+ self.context.invoke('editor.saveRange');
+ self.context.invoke('editor.afterCommand');
+ });
+ return $button;
+ },
+ /**
+ * Creates a (fore- or back-) color palette.
+ *
+ * @param {string('foreColor'|'backColor')} eventName
+ * @returns {jQuery}
+ */
+ createPalette: function (eventName) {
+ var self = this;
+ var colors = _.clone(this.options.colors);
+ colors.splice(0, 1); // Ignore the summernote gray palette and use ours
+ var $palette = $(QWeb.render('wysiwyg.plugin.font.colorPalette', {
+ colors: colors,
+ eventName: eventName,
+ lang: this.lang,
+ }));
+ if (this.options.tooltip) {
+ $palette.find('.note-color-btn').tooltip({
+ container: this.options.container,
+ trigger: 'hover',
+ placement: 'bottom'
+ });
+ }
+
+ // custom structure for the color picker and custom colorsin XML template
+ var $clpicker = $(QWeb.render('web_editor.colorpicker'));
+
+ var $buttons = $(_.map($clpicker.children(), function (group) {
+ var $contents = $(group).contents();
+ if (!$contents.length) {
+ return '';
+ }
+ var $row = $("<div/>", {
+ "class": "note-color-row mb8 clearfix",
+ 'data-group': $(group).data('name'),
+ }).append($contents);
+ var $after_breaks = $row.find(".o_small + :not(.o_small)");
+ if ($after_breaks.length === 0) {
+ $after_breaks = $row.find(":nth-child(8n+9)");
+ }
+ $after_breaks.addClass("o_clear");
+ return $row[0].outerHTML;
+ }).join(""));
+
+ $buttons.find('button').each(function () {
+ var color = $(this).data('color');
+ $(this).addClass('note-color-btn bg-' + color).attr('data-value', (eventName === 'backColor' ? 'bg-' : 'text-') + color);
+ });
+
+ $palette.find('.o_theme_color_placeholder').prepend($buttons.filter('[data-group="theme"]'));
+ $palette.find('.o_transparent_color_placeholder').prepend($buttons.filter('[data-group="transparent_grayscale"]'));
+ $palette.find('.o_common_color_placeholder').prepend($buttons.filter('[data-group="common"]'));
+
+ $palette.off('click').on('mousedown', '.note-color-btn', function (e) {
+ e.preventDefault();
+ self.context.invoke('editor.createRange');
+ self.context.invoke('editor.beforeCommand');
+ var method = eventName === 'backColor' ? 'changeBgColor' : 'changeForeColor';
+ var $target = $(e.target);
+ self.context.invoke('FontPlugin.' + method, $target.closest('[data-value]').data('value'), $target);
+ self.context.invoke('buttons.updateCurrentStyle');
+ self.editable.normalize();
+ self.context.invoke('editor.saveRange');
+ self.context.invoke('editor.afterCommand');
+ });
+ $palette.on('mousedown', '.note-custom-color', this._onCustomColor.bind(this, eventName));
+
+ return $palette;
+ },
+ /**
+ * Change the selection's fore color.
+ *
+ * @param {string} color (hexadecimal or class name)
+ */
+ changeForeColor: function (color) {
+ this._applyFont(color || 'text-undefined', null, null);
+ },
+ /**
+ * Change the selection's background color.
+ *
+ * @param {string} color (hexadecimal or class name)
+ */
+ changeBgColor: function (color) {
+ this._applyFont(null, color || 'bg-undefined', null);
+ },
+ /**
+ * Change the selection's font size.
+ *
+ * @param {integer} fontsize
+ */
+ changeFontSize: function (fontsize) {
+ this._applyFont(null, null, fontsize || 'inherit');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Applies the given styles (fore- or backcolor, font size)
+ * to a given <font> node.
+ *
+ * @private
+ * @param {Node} node
+ * @param {string} color (hexadecimal or class name)
+ * @param {string} bgcolor (hexadecimal or class name)
+ * @param {integer} size
+ * @returns {Node} the <font> node
+ */
+ _applyStylesToFontNode: function (node, color, bgcolor, size) {
+ var className = node.className.split(this.context.invoke('HelperPlugin.getRegex', 'space'));
+ var k;
+ if (color) {
+ for (k = 0; k < className.length; k++) {
+ if (className[k].length && className[k].slice(0, 5) === "text-") {
+ className.splice(k, 1);
+ k--;
+ }
+ }
+ if (color === 'text-undefined') {
+ node.className = className.join(" ");
+ node.style.color = "inherit";
+ } else if (color.indexOf('text-') !== -1) {
+ node.className = className.join(" ") + " " + color;
+ node.style.color = "inherit";
+ } else {
+ node.className = className.join(" ");
+ node.style.color = color;
+ }
+ }
+ if (bgcolor) {
+ for (k = 0; k < className.length; k++) {
+ if (className[k].length && className[k].slice(0, 3) === "bg-") {
+ className.splice(k, 1);
+ k--;
+ }
+ }
+
+ if (bgcolor === 'bg-undefined') {
+ node.className = className.join(" ");
+ node.style.backgroundColor = "inherit";
+ } else if (bgcolor.indexOf('bg-') !== -1) {
+ node.className = className.join(" ") + " " + bgcolor;
+ node.style.backgroundColor = "inherit";
+ } else {
+ node.className = className.join(" ");
+ node.style.backgroundColor = bgcolor;
+ }
+ }
+ if (size) {
+ node.style.fontSize = "inherit";
+ if (!isNaN(size) && Math.abs(parseInt(this.window.getComputedStyle(node).fontSize, 10) - size) / size > 0.05) {
+ node.style.fontSize = size + "px";
+ }
+ }
+ return node;
+ },
+ /**
+ * Applies the given styles (fore- or backcolor, font size) to the selection.
+ * If no text is selected, apply to the current text node, if any.
+ *
+ * @private
+ * @param {string} color (hexadecimal or class name)
+ * @param {string} bgcolor (hexadecimal or class name)
+ * @param {integer} fontsize
+ */
+ _applyFont: function (color, bgcolor, size) {
+ var self = this;
+ var r = this.context.invoke('editor.createRange');
+ if (!r || !this.$editable.has(r.sc).length || !this.$editable.has(r.ec).length) {
+ return;
+ }
+ var target;
+ var font;
+ if (r.isCollapsed()) {
+ if (dom.isIcon(r.sc)) {
+ target = dom.lastAncestor(r.sc, dom.isIcon);
+ } else {
+ target = this.context.invoke('editor.restoreTarget');
+ if (target && dom.isIcon(target)) {
+ r = this.context.invoke('editor.setRange', target, 0);
+ } else if (dom.isText(r.sc)) {
+ font = dom.create("font");
+ font.appendChild(this.document.createTextNode('\u200B'));
+
+ var fontParent = dom.ancestor(r.sc, function (n) {
+ return n.tagName === 'FONT';
+ });
+ var right;
+ if (fontParent) {
+ right = this.context.invoke('HelperPlugin.splitTree', fontParent, r.getStartPoint());
+ } else {
+ right = r.sc.splitText(r.so);
+ }
+ $(right).before(font);
+ font = this._applyStylesToFontNode(font, color, bgcolor, size);
+ r = this.context.invoke('editor.setRange', font, 1);
+ r.select();
+ return;
+ }
+ }
+ }
+
+ var startPoint = r.getStartPoint();
+ var endPoint = r.getEndPoint();
+ if (startPoint.node.tagName && startPoint.node.childNodes[startPoint.offset]) {
+ startPoint.node = startPoint.node.childNodes[startPoint.offset];
+ startPoint.offset = 0;
+ }
+ if (endPoint.node.tagName && endPoint.node.childNodes[endPoint.offset]) {
+ endPoint.node = endPoint.node.childNodes[endPoint.offset];
+ endPoint.offset = 0;
+ }
+ // get first and last point
+ var ancestor;
+ var node;
+ if (!r.isCollapsed()) {
+ if (endPoint.offset && endPoint.offset !== dom.nodeLength(endPoint.node)) {
+ ancestor = dom.lastAncestor(endPoint.node, dom.isFont) || endPoint.node;
+ dom.splitTree(ancestor, endPoint);
+ }
+ if (startPoint.offset && startPoint.offset !== dom.nodeLength(startPoint.node)) {
+ ancestor = dom.lastAncestor(startPoint.node, dom.isFont) || startPoint.node;
+ node = dom.splitTree(ancestor, startPoint);
+ if (endPoint.node === startPoint.node) {
+ endPoint.node = node;
+ endPoint.offset = dom.nodeLength(node);
+ }
+ startPoint.node = node;
+ startPoint.offset = 0;
+ }
+ }
+ // get list of nodes to change
+ var nodes = [];
+ dom.walkPoint(startPoint, endPoint, function (point) {
+ var node = point.node;
+ if (((dom.isText(node) && self.context.invoke('HelperPlugin.isVisibleText', node)) || dom.isIcon(node)) &&
+ (node !== endPoint.node || endPoint.offset)) {
+ nodes.push(point.node);
+ }
+ });
+ nodes = _.unique(nodes);
+ // if fontawesome
+ if (r.isCollapsed()) {
+ nodes.push(startPoint.node);
+ }
+
+ // apply font: foreColor, backColor, size (the color can be use a class text-... or bg-...)
+ var $font;
+ var fonts = [];
+ var style;
+ var className;
+ var i;
+ if (color || bgcolor || size) {
+ for (i = 0; i < nodes.length; i++) {
+ node = nodes[i];
+ font = dom.lastAncestor(node, dom.isFont);
+ if (!font) {
+ if (node.textContent.match(this.context.invoke('HelperPlugin.getRegex', 'startAndEndSpace'))) {
+ node.textContent = node.textContent.replace(this.context.invoke('HelperPlugin.getRegex', 'startAndEndSpace', 'g'), '\u00A0');
+ }
+ font = dom.create("font");
+ node.parentNode.insertBefore(font, node);
+ font.appendChild(node);
+ }
+ fonts.push(font);
+ this._applyStylesToFontNode(font, color, bgcolor, size);
+ }
+ }
+ // remove empty values
+ // we must remove the value in 2 steps (applay inherit then remove) because some
+ // browser like chrome have some time an error for the rendering and/or keep inherit
+ for (i = 0; i < fonts.length; i++) {
+ font = fonts[i];
+ if (font.style.color === "inherit") {
+ font.style.color = "";
+ }
+ if (font.style.backgroundColor === "inherit") {
+ font.style.backgroundColor = "";
+ }
+ if (font.style.fontSize === "inherit") {
+ font.style.fontSize = "";
+ }
+ $font = $(font);
+ if (font.style.color === '' && font.style.backgroundColor === '' && font.style.fontSize === '') {
+ $font.removeAttr("style");
+ }
+ if (!font.className.length) {
+ $font.removeAttr("class");
+ }
+ }
+
+ // target the deepest node
+ if (startPoint.node.tagName && !startPoint.offset) {
+ startPoint.node = this.context.invoke('HelperPlugin.firstLeaf', startPoint.node.childNodes[startPoint.offset] || startPoint.node);
+ startPoint.offset = 0;
+ }
+ if (endPoint.node.tagName && !endPoint.offset) {
+ endPoint.node = this.context.invoke('HelperPlugin.firstLeaf', endPoint.node.childNodes[endPoint.offset] || endPoint.node);
+ endPoint.offset = 0;
+ }
+
+ // select nodes to clean (to remove empty font and merge same nodes)
+ nodes = [];
+ dom.walkPoint(startPoint, endPoint, function (point) {
+ nodes.push(point.node);
+ });
+ nodes = _.unique(nodes);
+ // remove node without attributes (move content), and merge the same nodes
+ for (i = 0; i < nodes.length; i++) {
+ node = nodes[i];
+ if (dom.isText(node) && !this.context.invoke('HelperPlugin.isVisibleText', node)) {
+ continue;
+ }
+ font = dom.lastAncestor(node, dom.isFont);
+ node = font || dom.ancestor(node, dom.isSpan);
+ if (!node) {
+ continue;
+ }
+ $font = $(node);
+ className = this.context.invoke('HelperPlugin.orderClass', node);
+ style = this.context.invoke('HelperPlugin.orderStyle', node);
+ if (!className && !style) {
+ $(node).before($(node).contents());
+ if (endPoint.node === node) {
+ endPoint = dom.prevPointUntil(endPoint, function (point) {
+ return point.node !== node;
+ });
+ }
+ $(node).remove();
+
+ nodes.splice(i, 1);
+ i--;
+ continue;
+ }
+ var prev = font && font.previousSibling;
+ while (prev && !font.tagName && !this.context.invoke('HelperPlugin.isVisibleText', prev)) {
+ prev = prev.previousSibling;
+ }
+ if (prev &&
+ font.tagName === prev.tagName &&
+ className === this.context.invoke('HelperPlugin.orderClass', prev) && style === this.context.invoke('HelperPlugin.orderStyle', prev)) {
+ $(prev).append($(font).contents());
+ if (endPoint.node === font) {
+ endPoint = dom.prevPointUntil(endPoint, function (point) {
+ return point.node !== font;
+ });
+ }
+ $(font).remove();
+
+ nodes.splice(i, 1);
+ i--;
+ continue;
+ }
+ }
+
+ // restore selection
+ r = this.context.invoke('editor.setRange', startPoint.node, startPoint.offset, endPoint.node, endPoint.offset);
+ r.normalize().select();
+
+ if (target) {
+ this.context.invoke('MediaPlugin.updatePopoverAfterEdit', target);
+ }
+ },
+ /**
+ * Creates the backcolor button containing its palette.
+ *
+ * @private
+ * @returns {jQuery} the backcolor button
+ */
+ _createBackColorButton: function () {
+ var $bgPalette = this.createPalette('backColor');
+ $bgPalette.find("button:not(.note-color-btn)")
+ .addClass("note-color-btn")
+ .attr('data-event', 'backColor')
+ .each(function () {
+ var $el = $(this);
+ var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'bg-' + $el.data('color');
+ $el.attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : className);
+ });
+
+ var $bgContainer = $(QWeb.render('wysiwyg.plugin.font.paletteButton', {
+ className: 'note-bg-color',
+ icon: this.options.icons.bg,
+ }));
+ $bgContainer.find('.dropdown-menu').append($bgPalette);
+ return $bgContainer;
+ },
+ /**
+ * Creates the forecolor button containing its palette.
+ *
+ * @private
+ * @returns {jQuery} the forecolor button
+ */
+ _createForeColorButton: function () {
+ var $forePalette = this.createPalette('foreColor');
+ $forePalette.find("button:not(.note-color-btn)")
+ .addClass("note-color-btn")
+ .attr('data-event', 'foreColor')
+ .each(function () {
+ var $el = $(this);
+ var className = $el.hasClass('o_custom_color') ? $el.data('color') : 'text-' + $el.data('color');
+ $el.attr('data-value', className).addClass($el.hasClass('o_custom_color') ? '' : 'bg-' + $el.data('color'));
+ });
+
+ var $foreContainer = $(QWeb.render('wysiwyg.plugin.font.paletteButton', {
+ className: 'note-fore-color',
+ icon: this.options.icons.fore,
+ }));
+ $foreContainer.find('.dropdown-menu').append($forePalette);
+ return $foreContainer;
+ },
+ /**
+ * Method called on custom color button click :
+ * opens the color picker dialog and saves the chosen color on save.
+ *
+ * @private
+ * @param {string} targetColor
+ * @param {jQuery Event} ev
+ */
+ _onCustomColor: function (targetColor, ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ var self = this;
+ var $button = $(ev.target).next('button');
+ var target = this.context.invoke('editor.restoreTarget');
+ var colorPickerDialog = new ColorpickerDialog(this, {});
+
+ this.context.invoke('editor.saveRange');
+ colorPickerDialog.on('colorpicker:saved', this, this._wrapCommand(function (ev) {
+ $button = $button.clone().appendTo($button.parent());
+ $button.show();
+ $button.css('background-color', ev.data.cssColor);
+ $button.attr('data-value', ev.data.cssColor);
+ $button.data('value', ev.data.cssColor);
+ $button.attr('title', ev.data.cssColor);
+ self.context.invoke('editor.saveTarget', target);
+ self.context.invoke('editor.restoreRange');
+ $button.mousedown();
+ }));
+ colorPickerDialog.open();
+ this.context.invoke('MediaPlugin.hidePopovers');
+ },
+});
+
+_.extend(wysiwygOptions.icons, {
+ fore: 'fa fa-font',
+ bg: 'fa fa-paint-brush',
+});
+_.extend(wysiwygTranslation.color, {
+ customColor: _t('Custom color'),
+ fore: _t('Color'),
+ bg: _t('Background color'),
+});
+
+registry.add('FontPlugin', FontPlugin);
+
+registry.addXmlDependency('/web_editor/static/src/xml/wysiwyg_colorpicker.xml');
+registry.addJob(function (wysiwyg) {
+ if ('web_editor.colorpicker' in QWeb.templates) {
+ return;
+ }
+ var options = {};
+ wysiwyg.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+ return wysiwyg._rpc({
+ model: 'ir.ui.view',
+ method: 'read_template',
+ args: ['web_editor.colorpicker'],
+ kwargs: {
+ context: options.context,
+ },
+ }).then(function (template) {
+ QWeb.add_template(template);
+ });
+});
+
+
+return FontPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js b/addons/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js
new file mode 100644
index 00000000000..f30b16b8bfc
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/font_buttons.js
@@ -0,0 +1,31 @@
+odoo.define('web_editor.wysiwyg.plugin.font.buttons', function (require) {
+'use strict';
+
+var ButtonsPlugin = require('web_editor.wysiwyg.plugin.buttons');
+
+//--------------------------------------------------------------------------
+// override the ColorPicker button into the Toolbar and the font size button
+//--------------------------------------------------------------------------
+
+ButtonsPlugin.include({
+ /**
+ * @override
+ */
+ addToolbarButtons: function () {
+ var self = this;
+ this._super();
+
+ this.context.memo('button.colorpicker', function () {
+ return self.context.invoke('FontPlugin.createColorPickerButtons');
+ });
+
+ var fontsizeFunction = this.context.memo('button.fontsize');
+ this.context.memo('button.fontsize', function () {
+ return self.context.invoke('FontPlugin.overrideFontSizeButton', fontsizeFunction());
+ });
+ },
+});
+
+return ButtonsPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js b/addons/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js
new file mode 100644
index 00000000000..2a28cbb221d
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/help_dialog.js
@@ -0,0 +1,27 @@
+odoo.define('web_editor.wysiwyg.plugin.HelpDialog', function (require) {
+'use strict';
+
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+/**
+ * Allows to customize link content and style.
+ */
+var HelpDialog = Plugins.helpDialog.extend({
+ /**
+ * Restore the hidden close button.
+ */
+ showHelpDialog: function () {
+ var self = this;
+ return this._super().then(function () {
+ self.$dialog.find('button.close span').attr('aria-hidden', 'false');
+ });
+ },
+});
+
+registry.add('helpDialog', HelpDialog);
+
+return {
+ HelpDialog: HelpDialog,
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js b/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js
new file mode 100644
index 00000000000..098186c75a8
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/helper.js
@@ -0,0 +1,1843 @@
+odoo.define('web_editor.wysiwyg.plugin.helper', function (require) {
+'use strict';
+
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var Dialog = require('web.Dialog');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var dom = $.summernote.dom;
+
+
+var HelperPlugin = AbstractPlugin.extend({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * This dictionary contains oft-used regular expressions,
+ * for performance and readability purposes. It can be
+ * accessed and extended by the getRegex() method.
+ */
+ regex: {
+ char: {
+ noflag: /\S|\u00A0|\u200B/,
+ },
+ emptyElemWithBR: {
+ noflag: /^\s*<br\/?>\s*$/,
+ },
+ endInvisible: {
+ noflag: /\u200B$/,
+ },
+ endNotChar: {
+ noflag: /[^\S\u00A0\u200B]+$/,
+ },
+ endSingleSpace: {
+ noflag: /[\S\u00A0\u200B]\s$/,
+ },
+ endSpace: {
+ noflag: /\s+$/,
+ },
+ invisible: {
+ noflag: /\u200B/,
+ },
+ jinja: {
+ noflag: /(^|\n)\s*%\send|%\sset/,
+ },
+ notWhitespace: {
+ noflag: /\S/,
+ },
+ onlyEmptySpace: {
+ noflag: /^[\s\u00A0\u200B]*(<br>)?[\s\u00A0\u200B]*$/,
+ },
+ semicolon: {
+ noflag: / ?; ?/,
+ },
+ space: {
+ noflag: /\s+/,
+ g: /\s+/g,
+ },
+ spaceOrNewline: {
+ noflag: /[\s\n\r]+/,
+ g: /[\s\n\r]+/g,
+ },
+ startAndEndInvisible: {
+ noflag: /^\u200B|\u200B$/,
+ g: /^\u200B|\u200B$/g,
+ },
+ startAndEndSpace: {
+ noflag: /^\s+|\s+$/,
+ g: /^\s+|\s+$/g,
+ },
+ startAndEndSemicolon: {
+ noflag: /^ ?;? ?| ?;? ?$/,
+ },
+ startInvisible: {
+ noflag: /^\u200B/,
+ },
+ startNotChar: {
+ noflag: /^[^\S\u00A0\u200B]+/,
+ },
+ startSingleSpace: {
+ noflag: /^\s[\S\u00A0\u200B]/,
+ },
+ startSpace: {
+ noflag: /^\s+/,
+ },
+ },
+
+ /**
+ * Compares two nodes to see if they are similar.
+ * "Similar" means that they have the same tag, styles, classes and attributes.
+ *
+ * @param {Node} node
+ * @param {Node} otherNode
+ * @returns {Boolean} true if the nodes are similar
+ */
+ compareNodes: function (node, otherNode) {
+ if (!otherNode || !node) {
+ return false;
+ }
+ if (node.tagName !== otherNode.tagName) {
+ return false;
+ }
+ if (dom.isText(node)) {
+ return true;
+ }
+ this.removeBlankAttrs(node);
+ this.removeBlankAttrs(otherNode);
+ this.orderClass(node);
+ this.orderStyle(node);
+ this.orderClass(otherNode);
+ this.orderStyle(otherNode);
+ if (node.attributes.length !== otherNode.attributes.length) {
+ return false;
+ }
+ for (var i = 0; i < node.attributes.length; i++) {
+ var attr = node.attributes[i];
+ var otherAttr = otherNode.attributes[i];
+ if (attr.name !== otherAttr.name || attr.value !== otherAttr.value) {
+ return false;
+ }
+ }
+ return true;
+ },
+ /**
+ * Returns the number of leading breakable space in the textNode.
+ * Note: returns 0 if the node is not a textNode.
+ *
+ * @param {Node}
+ */
+ countLeadingBreakableSpace: function (node) {
+ if (!dom.isText(node)) {
+ return 0;
+ }
+ var clone = $(node).clone()[0];
+ var breakableSpace = this.removeExtremeBreakableSpace(clone, 0).start;
+ return breakableSpace === 1 ? 0 : breakableSpace;
+ },
+ /**
+ * Returns the number of trailing breakable space in the textNode.
+ * Note: returns 0 if the node is not a textNode.
+ *
+ * @param {Node} node
+ */
+ countTrailingBreakableSpace: function (node) {
+ if (!dom.isText(node)) {
+ return 0;
+ }
+ var clone = $(node).clone()[0];
+ var breakableSpace = this.removeExtremeBreakableSpace(clone, 0).end;
+ return breakableSpace === 1 ? 0 : breakableSpace;
+ },
+ /**
+ * Remove the dom between 2 points (respecting unbreakable rules).
+ * Returns an object:
+ * {
+ * node: pointA.node (updated if necessary),
+ * offset: pointA.offset (updated if necessary),
+ * changed: bool (whether changes were applied)
+ * }
+ *
+ * @param {Object} pointA
+ * @param {Node} pointA.node
+ * @param {Integer} pointA.offset
+ * @param {Object} pointB
+ * @param {Node} pointB.node
+ * @param {Integer} pointB.offset
+ * @returns {Object} {node, offset, changed}
+ */
+ deleteBetween: function (pointA, pointB) {
+ var self = this;
+ if (pointB.node.childNodes[pointB.offset]) {
+ var firstLeaf = this.firstLeaf(pointB.node.childNodes[pointB.offset]);
+ pointB = this.makeRange(firstLeaf, 0);
+ }
+ if (pointB.node.tagName && pointB.node.tagName !== 'BR' && pointB.offset >= dom.nodeLength(pointB.node)) {
+ pointB = dom.nextPoint(pointB);
+ }
+ var changed;
+ var commonAncestor = dom.commonAncestor(pointA.node, pointB.node);
+
+ var ecAncestor = dom.ancestor(pointB.node, function (node) {
+ return node === commonAncestor || self.options.isUnbreakableNode(node.parentNode);
+ });
+ var next = this.splitTree(ecAncestor, pointB, {
+ nextText: true,
+ });
+
+ var scAncestor = dom.ancestor(pointA.node, function (node) {
+ return node === commonAncestor || self.options.isUnbreakableNode(node.parentNode);
+ });
+ if (dom.isIcon(pointA.node)) {
+ pointA = dom.prevPoint(pointA);
+ }
+ this.splitTree(scAncestor, pointA, {
+ nextText: true,
+ });
+ pointA.offset = dom.nodeLength(pointA.node);
+
+ var nodes = [];
+ dom.nextPointUntil(pointA, function (point) {
+ if (point.node === next || !point.node) {
+ return true;
+ }
+ if (dom.isText(point.node) && point.offset) {
+ return;
+ }
+ var target = point.node.childNodes[point.offset] || point.node;
+ if (target === pointA.node || $.contains(target, pointA.node) || target === next || $.contains(target, next)) {
+ return;
+ }
+ if (
+ nodes.indexOf(target) === -1 && !dom.ancestor(target, function (target) {
+ return nodes.indexOf(target) !== -1;
+ })
+ ) {
+ nodes.push(target);
+ }
+ });
+ $(nodes).remove();
+
+ changed = !!nodes.length;
+ var toMerge = changed && pointA.node.parentNode !== next.parentNode;
+
+
+ var point = this.makePoint(this.firstLeaf(next), 0);
+ if (nodes.length > 1 || nodes.length && !dom.isText(nodes[0])) {
+ point = this.removeEmptyInlineNodes(point);
+ }
+
+ // Remove whole li/ul/ol if deleted all contents of li/ul/ol
+ var ul = dom.ancestor(next, function (n) {
+ return n.tagName === 'UL' || n.tagName === 'OL';
+ });
+ if (ul && next[dom.isText(next) ? 'textContent' : 'innerHTML'] === '' && pointA.node !== next.previousSibling) {
+ var toRemove = next;
+ while (
+ toRemove !== ul && toRemove.parentNode &&
+ !this.options.isUnbreakableNode(toRemove.parentNode) &&
+ this.isBlankNode(toRemove.parentNode)
+ ) {
+ toRemove = toRemove.parentNode;
+ }
+ $(toRemove).remove();
+ }
+ if (!$.contains(this.editable, pointA.node)) {
+ pointA = point;
+ }
+
+ if (toMerge) {
+ pointA = this.deleteEdge(pointA.node, 'next') || pointA;
+ }
+
+ return {
+ node: pointA.node,
+ offset: pointA.offset,
+ changed: changed,
+ };
+ },
+ /**
+ * Remove the edge between a node and its sibling
+ * (= merge the nodes, respecting unbreakable rules).
+ *
+ * @param {Node} node
+ * @param {String('next'|'prev')} direction
+ * @param {Boolean} doNotTryNonSim true to not try merging non-similar nodes
+ * @returns {Object} {node, offset}
+ */
+ deleteEdge: function (node, direction, doNotTryNonSim) {
+ var prevOrNext = direction === 'prev' ? 'previousSibling' : 'nextSibling';
+ var result = false;
+ var startN = node;
+
+ if (node.tagName === 'BR' && node.nextSibling && !(dom.isText(node.nextSibling) && !this.isVisibleText(node.nextSibling))) {
+ node = node.nextSibling;
+ node = this.firstLeaf(node);
+ }
+
+ var nodes = [];
+ var next;
+ while (node && node !== this.editable && !this.options.isUnbreakableNode(node)) {
+ nodes.push(node);
+
+ next = node[prevOrNext];
+ while (next && !next.tagName) {
+ if (!this.getRegex('char').test(next.textContent)) {
+ next = next[prevOrNext];
+ continue;
+ }
+ break;
+ }
+
+ if (next) {
+ break;
+ }
+ node = node.parentNode;
+ }
+
+ if (next && next.tagName === 'TABLE') {
+ return this.makePoint(node, 0);
+ }
+
+ var ifBrRemovedAndMerge = !_.filter(nodes, this.isNodeBlockType.bind(this)).length;
+ var brRemoved = false;
+
+ var range = this.context.invoke('editor.createRange');
+
+ var spaceToRemove = [];
+ while ((node = nodes.pop())) {
+ next = node[prevOrNext];
+ while (next && !next.tagName) {
+ if (!this.getRegex('char').test(next.textContent)) {
+ spaceToRemove.push(next);
+ next = next[prevOrNext];
+ continue;
+ }
+ break;
+ }
+ if (
+ !next ||
+ !(node.tagName || next.tagName === 'BR') ||
+ !next.tagName
+ ) {
+ continue;
+ }
+
+ if (!brRemoved && next.tagName === 'BR' && (!next[prevOrNext] || this.compareNodes(node, next[prevOrNext]))) {
+ var newNext = next[prevOrNext];
+ $(next).remove();
+ next = newNext;
+ var offset = (next ? direction === 'prev' : direction === 'next') ? dom.nodeLength(next) : 0;
+ result = this.makePoint(next || node, offset);
+ if (!ifBrRemovedAndMerge) {
+ continue;
+ }
+ brRemoved = true;
+ ifBrRemovedAndMerge = false;
+ }
+
+ if (!this.compareNodes(node, next)) {
+ continue;
+ }
+ _.each(spaceToRemove, function (space) {
+ $(space).remove();
+ });
+ spaceToRemove = [];
+ next = node[prevOrNext];
+ var $next = $(next);
+ if (next.tagName) {
+ var textNode;
+ var nextTextNode;
+ var deep;
+ if (direction === 'prev') {
+ textNode = this.firstLeaf(node);
+ if (!textNode.tagName && !dom.ancestor(textNode, dom.isPre)) {
+ this.removeExtremeBreakableSpace(textNode);
+ range.so = range.eo = 0;
+ nextTextNode = this.lastLeaf(next);
+ if (!nextTextNode.tagName && !dom.ancestor(nextTextNode, dom.isPre)) {
+ this.removeExtremeBreakableSpace(nextTextNode);
+ }
+ }
+ deep = this.lastLeaf(next);
+ result = this.makePoint(deep, dom.nodeLength(deep));
+ if (
+ this.getRegex('char').test(node.textContent) || node.childElementCount > 1 ||
+ node.firstElementChild && node.firstElementChild.tagName !== "BR"
+ ) {
+ $next.append($(node).contents());
+ }
+ $(node).remove();
+ } else {
+ nextTextNode = this.firstLeaf(next);
+ if (!nextTextNode.tagName && !dom.ancestor(nextTextNode, dom.isPre)) {
+ this.removeExtremeBreakableSpace(nextTextNode);
+ textNode = this.lastLeaf(node);
+ if (!textNode.tagName && !dom.ancestor(textNode, dom.isPre)) {
+ this.removeExtremeBreakableSpace(textNode);
+ range.so = range.eo = dom.nodeLength(node);
+ }
+ }
+ if (node.innerHTML.trim() === '<br>') {
+ $(node).contents().remove();
+ }
+ deep = this.lastLeaf(node);
+ result = this.makePoint(deep, dom.nodeLength(deep));
+ $(node).append($next.contents());
+ $next.remove();
+ }
+ continue;
+ } else if (!this.getRegex('char').test(next.textContent)) {
+ result = this.makePoint(node, direction === 'prev' ? 0 : dom.nodeLength(node));
+ $next.remove();
+ continue;
+ }
+
+ break;
+ }
+
+ if (!result && startN && !doNotTryNonSim) {
+ result = this.deleteNonSimilarEdge(startN, direction);
+ }
+
+ return result;
+ },
+ /**
+ * Find and delete the previous/next non-similar edge if possible.
+ * "Similar" means that they have the same tag, styles, classes and attributes.
+ *
+ * @param {Node} node
+ * @param {String} direction 'prev' or 'next'
+ * @returns {false|Object} {node, offset}
+ */
+ deleteNonSimilarEdge: function (node, direction) {
+ var next = node[direction === 'next' ? 'nextSibling' : 'previousSibling'];
+ while (
+ next && dom.isText(next) &&
+ this.getRegexBlank({
+ space: true,
+ invisible: true,
+ }).test(next.textContent)
+ ) {
+ next = next[direction === 'next' ? 'nextSibling' : 'previousSibling'];
+ }
+
+ if (next) {
+ return;
+ }
+
+ node = this.firstBlockAncestor(node);
+
+ if (this.options.isUnbreakableNode(node)) {
+ return;
+ }
+
+ var point = this.makePoint(node, direction === 'prev' ? 0 : dom.nodeLength(node));
+ var otherBlock = this.findNextBlockToMerge(point.node, direction);
+
+ if (!otherBlock) {
+ return;
+ }
+
+ var blockToMergeFrom = direction === 'next' ? otherBlock : point.node;
+ var blockToMergeInto = direction === 'next' ? point.node : otherBlock;
+
+ // empty tag are removed
+ if (
+ this.getRegexBlank({
+ space: true,
+ newline: true,
+ }).test(blockToMergeInto.textContent) &&
+ !$(blockToMergeInto).find('.fa').length && $(blockToMergeInto).find('br').length <= 1
+ ) {
+ $(blockToMergeInto).remove();
+ return this.makePoint(this.firstLeaf(blockToMergeFrom), 0);
+ }
+
+ return this.mergeNonSimilarBlocks(blockToMergeFrom, blockToMergeInto);
+ },
+ /**
+ * Deletes the contents of the selected DOM.
+ *
+ * @returns {Boolean} true if there was a selection to delete
+ */
+ deleteSelection: function () {
+ var range = this.context.invoke('editor.createRange');
+ if (range.isCollapsed()) {
+ return false;
+ }
+ var point = this.deleteBetween(range.getStartPoint(), range.getEndPoint());
+ point = this.fillEmptyNode(point);
+
+ range = this.context.invoke('editor.setRange', point.node, point.offset);
+ range = range.select();
+
+ this.editable.normalize();
+ this.context.invoke('editor.saveRange');
+
+ // remove tooltip when remove DOM nodes
+ $('body > .tooltip').tooltip('hide');
+
+ return true;
+ },
+ /**
+ * Fill up an empty node so as to allow the carret to go inside it.
+ * A block node will be filled with a <br>, with the offset before it.
+ * An inline node will be filled with two zero-width spaces, with the offset in between the two.
+ * Returns the given point (with the completed node and the updated offset).
+ *
+ * @param {Object} point {node, offset}
+ * @returns {Object} {node, offset}
+ */
+ fillEmptyNode: function (point) {
+ if (
+ !point.node.tagName && this.getRegexBlank({
+ space: true,
+ invisible: true,
+ nbsp: true,
+ }).test(point.node.parentNode.innerHTML)
+ ) {
+ point.node = point.node.parentNode;
+ point.offset = 0;
+ }
+ if (
+ point.node.tagName && point.node.tagName !== 'BR' &&
+ this.getRegexBlank({
+ space: true,
+ invisible: true,
+ nbsp: true,
+ }).test(point.node.innerHTML)
+ ) {
+ var text = this.document.createTextNode('');
+ point.node.innerHTML = '';
+ point.node.appendChild(text);
+ point.node = text;
+ point.offset = 0;
+ }
+ if (point.node.parentNode.innerHTML === '') {
+ if (this.isNodeBlockType(point.node.parentNode)) {
+ var node = point.node.parentNode;
+ node.innerHTML = '<br/>';
+ point.node = node.firstChild;
+ point.offset = 0;
+ } else {
+ point.node.textContent = '\u200B\u200B';
+ point.offset = 1;
+ }
+ }
+ return point;
+ },
+ /**
+ * Get the "format" ancestors list of nodes.
+ * In this context, a "format" node is understood as
+ * an editable block or an editable element expecting text
+ * (eg.: p, h1, span).
+ *
+ * @param {Node[]} nodes
+ * @returns {Node[]}
+ */
+ filterFormatAncestors: function (nodes) {
+ var self = this;
+ var selectedNodes = [];
+ _.each(this.filterLeafChildren(nodes), function (node) {
+ var ancestor = dom.ancestor(node, function (node) {
+ return dom.isCell(node) || (
+ !self.options.isUnbreakableNode(node) &&
+ (self.isFormatNode(node) || self.isNodeBlockType(node))
+ ) && self.editable !== node;
+ });
+ if (!ancestor) {
+ ancestor = node;
+ }
+ if (dom.isCell(ancestor)) {
+ ancestor = node;
+ }
+ if (ancestor && selectedNodes.indexOf(ancestor) === -1) {
+ selectedNodes.push(ancestor);
+ }
+ });
+ return selectedNodes;
+ },
+ /**
+ * Get the "leaf" children of a list of nodes.
+ * In this context, a "leaf" is understood as
+ * either a text node or a node that doesn't expect text contents.
+ *
+ * @param {Node[]} nodes
+ * @returns {Node[]}
+ */
+ filterLeafChildren: function (nodes) {
+ var self = this;
+ return _.compact(_.map(nodes, function (node) {
+ if (node.firstChild) {
+ node = node.firstChild;
+ }
+ if (
+ node.tagName === "BR" ||
+ self.isVisibleText(node) ||
+ dom.isFont(node) ||
+ dom.isImg(node) ||
+ dom.isDocument(node)
+ ) {
+ return node;
+ }
+ }));
+ },
+ /**
+ * Find the previous/next non-similar block to merge with.
+ * "Similar" means that they have the same tag, styles, classes and attributes.
+ *
+ * @param {Node} node
+ * @param {String} direction 'prev' or 'next
+ * @returns {false|Node}
+ */
+ findNextBlockToMerge: function (node, direction) {
+ var self = this;
+ var startNode = node;
+ var mergeableTags = this.options.styleTags.join(', ') + ', li';
+ var blockToMerge = false;
+
+ var li = dom.ancestor(node, function (n) {
+ return n !== node && self.isNodeBlockType(n) || dom.isLi(n);
+ });
+ li = li && dom.isLi(li) ? li : undefined;
+ if (li && direction === 'next') {
+ if (li.nextElementSibling) {
+ node = li;
+ } else {
+ node = dom.ancestor(node, function (n) {
+ return ((n.tagName === 'UL' || n.tagName === 'OL') && n.nextElementSibling);
+ });
+ }
+ }
+
+ if (!node || !node[direction === 'next' ? 'nextElementSibling' : 'previousElementSibling']) {
+ return false;
+ }
+
+ node = node[direction === 'next' ? 'nextElementSibling' : 'previousElementSibling'];
+
+ var ulFoldedSnippetNode = dom.ancestor(node, function (n) {
+ return $(n).hasClass('o_ul_folded');
+ });
+ var ulFoldedSnippetStartNode = dom.ancestor(startNode, function (n) {
+ return $(n).hasClass('o_ul_folded');
+ });
+ if (
+ (this.options.isUnbreakableNode(node) && (!ulFoldedSnippetNode || ulFoldedSnippetNode === this.editable)) &&
+ this.options.isUnbreakableNode(startNode) && (!ulFoldedSnippetStartNode || ulFoldedSnippetStartNode === this.editable)
+ ) {
+ return false;
+ }
+
+ node = this.firstBlockAncestor(node);
+
+ li = dom.ancestor(node, function (n) {
+ return n !== node && self.isNodeBlockType(n) || dom.isLi(n);
+ });
+ li = li && dom.isLi(li) ? li : undefined;
+ node = li || node;
+
+ if (node.tagName === 'UL' || node.tagName === 'OL') {
+ node = node[direction === 'next' ? 'firstElementChild' : 'lastElementChild'];
+ }
+
+ if (this.options.isUnbreakableNode(node)) {
+ return false;
+ }
+
+ if (node === startNode || $(node).has(startNode).length || $(startNode).has(node).length) {
+ return false;
+ }
+
+ var $mergeable = $(node).find('*').addBack()
+ .filter(mergeableTags)
+ .filter(function (i, n) {
+ if (!(n.tagName === 'LI' && $(n).find(mergeableTags).length)) {
+ return n;
+ }
+ });
+ if ($mergeable.length) {
+ blockToMerge = $mergeable[direction === 'next' ? 'first' : 'last']()[0] || false;
+ }
+
+ return blockToMerge;
+ },
+ /**
+ * Get the first ancestor of a node, that is of block type (or itself).
+ *
+ * @param {Node} node
+ * @returns {Node}
+ */
+ firstBlockAncestor: function (node) {
+ var self = this;
+ return dom.ancestor(node, function (n) {
+ return self.isNodeBlockType(n);
+ });
+ },
+ /**
+ * Get the first leaf of a node, that is editable and not a media.
+ * In this context, a leaf node is understood as a childless node.
+ *
+ * @param {Node} node
+ * @returns {Node}
+ */
+ firstLeaf: function (node) {
+ while (node.firstChild && !dom.isMedia(node) && this.options.isEditableNode(node)) {
+ node = node.firstChild;
+ }
+ return node;
+ },
+ /**
+ * Get the first leaf of a node, that is an element and not a BR.
+ * In this context, a leaf node is understood as a childless node.
+ *
+ * @param {Node} node
+ * @returns {Node} node
+ */
+ firstNonBRElementLeaf: function (node) {
+ while (node.firstElementChild && node.firstElementChild.tagName !== 'BR') {
+ node = node.firstElementChild;
+ }
+ return node;
+ },
+ /**
+ * Returns the node targeted by a path
+ *
+ * @param {Object[]} list of object (tagName, offset)
+ * @returns {Node}
+ */
+ fromPath: function (path) {
+ var node = this.editable;
+ var to;
+ path = path.slice();
+ while ((to = path.shift())) {
+ node = _.filter(node.childNodes, function (node) {
+ return !to.tagName && node.tagName === 'BR' || node.tagName === to.tagName;
+ })[to.offset];
+ }
+ return node;
+ },
+ /**
+ * Returns (and creates if necessary) a regular expression.
+ * If a regular expression with the given name exists, simply returns it.
+ * Otherwise, creates a new one with the given name, exp and flag.
+ *
+ * @param {String} name
+ * @param {String} [flag] optional
+ * @param {String} [exp] optional
+ * @returns {RegExp}
+ */
+ getRegex: function (name, flag, exp) {
+ var flagName = flag || 'noflag';
+ flag = flag || '';
+ // If the regular expression exists, but not with this flag:
+ // retrieve whichever version of it and apply the new flag to it,
+ // then save that new version in the `regex` object.
+ if (this.regex[name] && !this.regex[name][flagName]) {
+ if (exp) {
+ console.warn("A regular expression already exists with the name: " + name + ". The expression passed will be ignored.");
+ }
+ var firstVal = this.regex[name][Object.keys(this.regex[name])[0]];
+ this.regex[name][flagName] = new RegExp(firstVal, flag);
+ } else if (!this.regex[name]) {
+ // If the regular expression does not exist:
+ // save it into the `regex` object, with the name, expression
+ // and flag passed as arguments (if any).
+ if (!exp) {
+ throw new Error("Cannot find a regular expression with the name " + name + ". Pass an expression to create it.");
+ }
+ this.regex[name] = {};
+ this.regex[name][flagName] = new RegExp(exp, flag);
+ }
+ return this.regex[name][flagName];
+ },
+ /**
+ * Returns (and creates if necessary) a regular expression
+ * targetting a string made ONLY of some combination of the
+ * characters enabled with options.
+ * If a regular expression with the given options exists, simply returns it.
+ * eg: getRegexBlank({space: true, nbsp: true}) => /^[\s\u00A0]*$/
+ *
+ * @param {Object} [options] optional
+ * @param {Boolean} options.not ^ (not all that follows)
+ * @param {Boolean} options.space \s (a whitespace)
+ * @param {Boolean} options.notspace \S (not a whitespace)
+ * @param {Boolean} options.nbsp \u00A0 (a non-breakable space)
+ * @param {Boolean} options.invisible \u200B (a zero-width character)
+ * @param {Boolean} options.newline \n|\r (a new line or a carriage return)
+ * @param {Boolean} options.atLeastOne + (do not target blank strings)
+ * @returns {RegExp}
+ */
+ getRegexBlank: function (options) {
+ options = options || {};
+ var charMap = {
+ notspace: {
+ name: 'NotSpace',
+ exp: '\\S',
+ },
+ space: {
+ name: 'Space',
+ exp: '\\s',
+ },
+ nbsp: {
+ name: 'Nbsp',
+ exp: '\\u00A0',
+ },
+ invisible: {
+ name: 'Invisible',
+ exp: '\\u200B',
+ },
+ newline: {
+ name: 'Newline',
+ exp: '\\n\\r',
+ },
+ };
+ var name = 'only';
+ var exp = '';
+ var atLeastOne = options.atLeastOne;
+ options.atLeastOne = false;
+
+ // Build the expression and its name
+ if (options.not) {
+ name += 'Not';
+ exp += '^';
+ options.not = false;
+ }
+ _.each(options, function (value, key) {
+ if (value && charMap[key]) {
+ name += charMap[key].name;
+ exp += charMap[key].exp;
+ }
+ });
+
+ exp = '^[' + exp + ']' + (atLeastOne ? '+' : '*') + '$';
+ name += atLeastOne ? 'One' : '';
+ return this.getRegex(name, undefined, exp);
+ },
+ /**
+ * Returns a list of all selected nodes in the range.
+ *
+ * @returns {Node []}
+ */
+ getSelectedNodes: function () {
+ var range = this.context.invoke('editor.createRange');
+ if (!range.isCollapsed()) {
+ if (range.so && !range.sc.tagName) {
+ if (range.sc === range.ec) {
+ range.sc = range.ec = range.sc.splitText(range.so);
+ range.eo -= range.so;
+ } else {
+ range.sc = range.sc.splitText(range.so);
+ }
+ range.so = 0;
+ }
+ if (!range.ec.tagName && range.eo !== dom.nodeLength(range.ec) && !range.ec.tagName) {
+ range.ec.splitText(range.eo);
+ }
+ range.select();
+ }
+ var res = [range.sc];
+ var point = range.getStartPoint();
+ var prevNode = range.sc;
+ dom.nextPointUntil(point, function (pt) {
+ if (pt.node !== prevNode) {
+ var ok = true;
+ // Return only the smallest traversed children
+ _.each(res, function (n, i) {
+ // If pt.node is a child of res[i], replace it
+ if (dom.listAncestor(pt.node).indexOf(n) !== -1) {
+ res[i] = pt.node;
+ ok = false;
+ } else if (dom.listAncestor(n).indexOf(pt.node) !== -1) {
+ // Inversely, skip parents of res[i]
+ ok = false;
+ }
+ });
+ if (ok) {
+ res.push(pt.node);
+ }
+ }
+ prevNode = pt.node;
+ return pt.node === range.ec;
+ });
+ return _.uniq(res);
+ },
+ /**
+ * Returns a list of all selected text nodes in the range.
+ *
+ * @returns {Node []}
+ */
+ getSelectedText: function () {
+ var self = this;
+ var selectedText = _.filter(this.getSelectedNodes(), function (node) {
+ node = self.firstLeaf(node);
+ return self.isVisibleText(node);
+ });
+ return _.uniq(selectedText);
+ },
+ /**
+ * Returns true if the value contains jinja logic.
+ *
+ * @returns {Boolean}
+ */
+ hasJinja: function (value) {
+ return this.getRegex('jinja').test(value);
+ },
+ /**
+ * Inserts a block node (respecting the rules of unbreakable nodes).
+ * In order to insert the node, the DOM tree is split at the carret position.
+ * If there is a selection, it is deleted first.
+ *
+ * @param {Node} node
+ */
+ insertBlockNode: function (node) {
+ var self = this;
+ var range = this.context.invoke('editor.createRange');
+ range = range.deleteContents();
+ var point = range.getStartPoint();
+ var unbreakable = point.node;
+ if (!this.options.isUnbreakableNode(point.node)) {
+ unbreakable = dom.ancestor(point.node, function (node) {
+ return self.options.isUnbreakableNode(node.parentNode) || node === self.editable;
+ }) || point.node;
+ }
+
+ if (unbreakable === point.node && !point.offset && point.node.tagName !== 'P') {
+ if (point.node.innerHTML === '<br>') {
+ $(point.node.firstElementChild).remove();
+ }
+ if (point.node.tagName === "BR") {
+ $(point.node).replaceWith(node);
+ } else {
+ point.node.append(node);
+ }
+ return;
+ }
+ if (!this.options.isUnbreakableNode(point.node)) {
+ var tree = dom.splitTree(unbreakable, point, {
+ isSkipPaddingBlankHTML: true,
+ isNotSplitEdgePoint: true,
+ });
+ if ((!tree || $.contains(tree, range.sc)) && (point.offset || point.node.tagName)) {
+ tree = tree || dom.ancestor(point.node, function (node) {
+ return self.options.isUnbreakableNode(node.parentNode);
+ });
+ $(tree).after(node);
+ } else {
+ $(tree).before(node);
+ }
+ } else {
+ // prevent unwrapped text in unbreakable
+ if (dom.isText(unbreakable)) {
+ $(unbreakable).wrap(this.document.createElement('p'));
+ unbreakable.splitText(point.offset);
+ unbreakable = unbreakable.parentNode;
+ point.offset = 1;
+ }
+ $(unbreakable.childNodes[point.offset]).before(node);
+ }
+ if (range.sc.innerHTML === '<br>') {
+ var clone = range.sc.cloneNode(true);
+ if (node.previousSibling === range.sc) {
+ $(node).after(clone);
+ } else if (node.nextSibling === range.sc) {
+ $(node).before(clone);
+ }
+ }
+ },
+ /**
+ * Inserts a string as a text node in the DOM.
+ * If the range is on a text node, splits the text node first.
+ * Otherwise just inserts the text node.
+ * Wraps it in a P if needed.
+ *
+ * @param {String} text
+ */
+ insertTextInline: function (text) {
+ if (text === " ") {
+ text = "\u00A0";
+ }
+ this.editable.normalize();
+ this.deleteSelection();
+
+ var range = this.context.invoke('editor.createRange');
+
+
+ if (!range.sc.tagName && range.sc.textContent.match(/\S/)) {
+ var before = range.sc.textContent.slice(0, range.so);
+ var after = range.sc.textContent.slice(range.so);
+
+ if (
+ (before.length || after.length) &&
+ (!before.length || before[before.length - 1] === ' ') &&
+ (!after.length || after[0] === ' ')
+ ) {
+ var startSpace = this.getRegex('startSpace');
+ var endSpace = this.getRegex('endSpace');
+ before = before.replace(endSpace, '\u00A0');
+ after = after.replace(startSpace, '\u00A0');
+ range.sc.textContent = before + after;
+ if (range.so > before.length) {
+ range.so = range.eo = before.length;
+ }
+ range.select();
+ }
+ }
+
+
+ range = range.deleteContents();
+ range = this._standardizeRangeOnEdge(range);
+ var textNode = this._insertTextNodeInEditableArea(range, text);
+
+ // if the text node can't be inserted in the dom (not editable area) do nothing
+ if (!textNode) {
+ return;
+ }
+
+ this.secureExtremeSingleSpace(range.sc);
+
+ this._wrapTextWithP(textNode);
+
+ range = this.context.invoke('editor.setRange', textNode, text.length);
+ range.select();
+ this.context.invoke('editor.saveRange');
+
+ // Clean up and make leading/trailing/multiple space visible
+ this.normalize();
+ range = this.context.invoke('editor.createRange');
+ var reStartBlanks = this.getRegex('startBlanks', '', '^([\\s\\u00A0\\u200B]*)');
+ var reEndBlanks = this.getRegex('endBlanks', '', '([\\s\\u00A0\\u200B]*)$');
+ var reAllNBSP = /\u00A0/g;
+ var reMultipleSpace = /(\s){2,}/;
+
+ var startBlanks = range.sc.textContent.match(reStartBlanks)[0] || '';
+ var endBlanks = range.sc.textContent.match(reEndBlanks)[0] || '';
+ var trimmed = range.sc.textContent.replace(reStartBlanks, '').replace(reEndBlanks, '');
+ // Remove the single inner nbsp's and replace the multiple inner spaces with nbsp's
+ var cleanContents = trimmed.replace(reAllNBSP, ' ')
+ .replace(reMultipleSpace, function (space) {
+ return Array(space.length + 1).join('\u00A0');
+ });
+ // Keep the leading/trailing whitespace, nbsp's and zero-width chars
+ range.sc.textContent = startBlanks + cleanContents + endBlanks;
+
+ this._removeInvisibleChar(range);
+
+ range.select();
+ this.context.invoke('editor.saveRange');
+ },
+ /**
+ * Returns true if the node is a text node containing nothing
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ isBlankText: function (node) {
+ return dom.isText(node) &&
+ this.getRegexBlank({
+ not: true,
+ notspace: true,
+ nbsp: true,
+ invisible: true,
+ })
+ .test(node.textContent);
+ },
+ /**
+ * Returns true if the node is blank.
+ * In this context, a blank node is understood as
+ * a node expecting text contents (or with children expecting text contents)
+ * but without any.
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ isBlankNode: function (node) {
+ if (dom.isVoid(node) || dom.isIcon(node)) {
+ return false;
+ }
+ if (this.getRegexBlank({
+ space: true,
+ }).test(node[dom.isText(node) ? 'textContent' : 'innerHTML'])) {
+ return true;
+ }
+ if (node.childNodes.length && _.all(node.childNodes, this.isBlankNode.bind(this))) {
+ return true;
+ }
+ return false;
+ },
+ /**
+ * Returns true if the point is on the left/right edge of the first
+ * previous/next point with the given tag name (skips insignificant nodes).
+ *
+ * @param {Object} point
+ * @param {String} tagName
+ * @param {String('left'|'right')} side
+ * @returns {Boolean}
+ */
+ isEdgeOfTag: function (point, tagName, side) {
+ var method = side === 'left' ? 'isLeftEdgePoint' : 'isRightEdgePoint';
+ var prevOrNext = side === 'left' ? 'prev' : 'next';
+ var newPt;
+ var first = true;
+ while (point && point.node.tagName !== tagName) {
+ newPt = this.skipNodes(point, prevOrNext, function (pt) {
+ return pt.node.tagName === tagName && dom[method](pt);
+ });
+ if (newPt.node.tagName === tagName || newPt.node.tagName === 'BR') {
+ point = newPt;
+ break;
+ }
+ if (newPt === point && (!first || dom.isText(point.node) && !dom[method](point))) {
+ break;
+ }
+ point = dom[prevOrNext + 'Point'](newPt);
+ first = false;
+ }
+ if (!point) {
+ return false;
+ }
+ var ancestor = dom.ancestor(point.node, function (n) {
+ return n.tagName === tagName;
+ });
+ return !!(ancestor && dom[method + 'Of'](point, ancestor));
+ },
+ /**
+ * Returns true if the node is a "format" node.
+ * In this context, a "format" node is understood as
+ * an editable block or an editable element expecting text
+ * (eg.: p, h1, span).
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ isFormatNode: function (node) {
+ return node.tagName && this.options.styleTags.indexOf(node.tagName.toLowerCase()) !== -1;
+ },
+ /**
+ * Returns true if the node is within a table.
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ isInTable: function (node) {
+ return !!dom.ancestor(node, function (n) {
+ return n.tagName === 'TABLE';
+ });
+ },
+ /**
+ * Returns true if the point is on the left edge of a block node
+ * (skips insignificant nodes).
+ *
+ * @param {Object} point
+ * @returns {Boolean}
+ */
+ isLeftEdgeOfBlock: function (point) {
+ point = this.skipNodes(point, 'prev');
+ return dom.isLeftEdgePointOf(point, this.firstBlockAncestor(point.node));
+ },
+ /**
+ * Returns true if the point is on the left edge of the first
+ * previous point with the given tag name (skips insignificant nodes).
+ *
+ * @param {Object} point
+ * @param {String} tagName
+ * @returns {Boolean}
+ */
+ isLeftEdgeOfTag: function (point, tagName) {
+ return this.isEdgeOfTag(point, tagName, 'left');
+ },
+ /**
+ * Returns true if the node is a block.
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ isNodeBlockType: function (node) {
+ if (dom.isText(node)) {
+ return false;
+ }
+ var display = this.window.getComputedStyle(node).display;
+ // All inline elements have the word 'inline' in their display value, except 'contents'
+ return display.indexOf('inline') === -1 && display !== 'contents';
+ },
+ /**
+ * Returns true if the point is on the right edge of the first
+ * next point with the given tag name (skips insignificant nodes).
+ *
+ * @param {Object} point
+ * @param {String} tagName
+ * @returns {Boolean}
+ */
+ isRightEdgeOfTag: function (point, tagName) {
+ return this.isEdgeOfTag(point, tagName, 'right');
+ },
+ /**
+ * Returns true if point should be ignored.
+ * This is generally used for trying to figure out if the point is an edge point.
+ *
+ * @param {Object} point
+ * @param {String} direction ('prev' or 'next')
+ * @param {Object} options
+ * @param {Boolean} options.noSkipBlankText true to not skip blank text
+ * @param {Boolean} options.noSkipSingleBRs true to not skip single BRs
+ * @param {Boolean} options.noSkipExtremeBreakableSpace true to not skip leading/trailing breakable space
+ * @param {Boolean} options.noSkipParent true to not skip to leaf nodes or offset 0
+ * @param {Boolean} options.noSkipSibling true to not skip if on edge and sibling is skippable
+ * @returns {Boolean}
+ */
+ isSkippable: function (point, direction, options) {
+ options = options || {};
+ var isEdge = direction === 'prev' ? dom.isLeftEdgePoint(point) : dom.isRightEdgePoint(point);
+
+ // skip blank text nodes
+ if (
+ !options.noSkipBlankText &&
+ this.isBlankText(point.node)
+ ) {
+ return true;
+ }
+ // skip single BRs
+ if (
+ !options.noSkipSingleBRs &&
+ point.node.tagName === 'BR' &&
+ (!point.node.previousSibling || this.isBlankText(point.node.previousSibling)) &&
+ (!point.node.nextSibling || this.isBlankText(point.node.nextSibling))
+ ) {
+ return true;
+ }
+ // skip leading/trailing breakable space
+ if (
+ !options.noSkipExtremeBreakableSpace &&
+ (direction === 'prev' && !isEdge && point.offset <= this.countLeadingBreakableSpace(point.node) ||
+ direction === 'next' && point.offset > dom.nodeLength(point.node) - this.countTrailingBreakableSpace(point.node))
+ ) {
+ return true;
+ }
+ // skip to leaf node or edge
+ var node = direction === 'prev' ? point.node.childNodes[0] : point.node.childNodes[point.node.childNodes.length - 1];
+ var offset = direction === 'prev' ? 0 : dom.nodeLength(node);
+ if (
+ !options.noSkipParent &&
+ !isEdge && point.node.childNodes.length &&
+ this.isSkippable(this.makePoint(node, offset), direction, options)
+ ) {
+ return true;
+ }
+ // skip if on edge and sibling is skippable
+ var sibling = direction === 'prev' ? point.node.previousSibling : point.node.nextSibling;
+ offset = direction === 'prev' ? 0 : dom.nodeLength(sibling);
+ if (
+ !options.noSkipSibling &&
+ isEdge && sibling &&
+ this.isSkippable(this.makePoint(sibling, offset), direction, _.defaults({
+ noSkipSibling: true,
+ }, options))
+ ) {
+ return true;
+ }
+ return false;
+ },
+ /**
+ * Returns true if the node is a text node with visible text.
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ isVisibleText: function (node) {
+ return !node.tagName && this.getRegex('char').test(node.textContent);
+ },
+ /**
+ * Get the last leaf of a node, that is editable and not a media.
+ * In this context, a leaf node is understood as a childless node.
+ *
+ * @param {Node} node
+ * @returns {Node}
+ */
+ lastLeaf: function (node) {
+ while (node.lastChild && !dom.isMedia(node) && this.options.isEditableNode(node)) {
+ node = node.lastChild;
+ }
+ return node;
+ },
+ /**
+ * Returns a Point object from a node and an offset.
+ *
+ * @param {Node} node
+ * @param {Number} offset
+ */
+ makePoint: function (node, offset) {
+ return {
+ node: node,
+ offset: offset,
+ };
+ },
+ /**
+ * Merges mergeFromBlock into mergeIntoBlock, respecting the rules of unbreakable.
+ *
+ * @param {Node} mergeFromBlock block to merge from
+ * @param {Node} mergeIntoBlock block to merge into
+ * @returns {Object} {node, offset}
+ */
+ mergeNonSimilarBlocks: function (mergeFromBlock, mergeIntoBlock) {
+ var point;
+ var mergeableTags = this.options.styleTags.join(', ') + ', li';
+ var $contents = $(mergeFromBlock).find('*').addBack()
+ .filter(mergeableTags)
+ .filter(function (i, n) {
+ if (!(n.tagName === 'LI' && $(n).find(mergeableTags).length)) {
+ return n;
+ }
+ }).contents();
+ var containsUnbreakables = !!$contents.filter(this.options.isUnbreakable).length;
+
+ if ($contents.length && !containsUnbreakables) {
+ if (dom.isText($contents[0])) {
+ this.removeExtremeBreakableSpace($contents[0]);
+ }
+ var $lastContents = $(mergeIntoBlock).contents().last();
+ if (!($contents.length === 1 && $contents[0].tagName === 'BR')) {
+ if (mergeIntoBlock.innerHTML.trim() === '<br>') {
+ $(mergeIntoBlock).contents().remove();
+ $(mergeIntoBlock).append($contents);
+ $lastContents = false;
+ } else {
+ $lastContents.after($contents);
+ }
+ }
+ while (mergeFromBlock.parentNode && this.isBlankNode(mergeFromBlock.parentNode)) {
+ mergeFromBlock = mergeFromBlock.parentNode;
+ }
+ $(mergeFromBlock).remove();
+
+ point = {};
+ if ($lastContents && $lastContents.length) {
+ point = this.makePoint($lastContents[0], dom.nodeLength($lastContents[0]));
+ } else {
+ point = this.makePoint($contents[0], 0);
+ }
+
+ point = this.deleteEdge(point.node, 'next', true) || point;
+ }
+ return point;
+ },
+ /**
+ * Normalize the DOM and range.
+ */
+ normalize: function () {
+ this.editable.normalize();
+ var range = this.context.invoke('editor.createRange');
+ var rangeN = range.normalize();
+
+ // summernote's normalize function fails when br in text,
+ // and targets the br instead of the point just after br.
+ var point = rangeN.getStartPoint();
+ if (point.node.tagName === "BR") {
+ point = dom.nextPoint(point);
+ }
+ if (point.node.tagName && point.node.childNodes[point.offset]) {
+ point = dom.nextPoint(point);
+ }
+ if (point.node.tagName === "BR") {
+ point = dom.nextPoint(point);
+ }
+ if (point.node !== range.sc || point.offset !== range.so) {
+ range = this.context.invoke('editor.setRange', point.node, point.offset);
+ range.select();
+ }
+ },
+ /**
+ * Simulate a do_notify by notifying the user through a dialog.
+ *
+ * @param {String} title
+ * @param {String} content
+ */
+ notify: function (title, content) {
+ var $notif = $('<p>' + content + '</p>');
+ new Dialog(this, {
+ title: title,
+ size: 'medium',
+ $content: $notif,
+ }).open();
+ },
+ /**
+ * Reorders the classes in the node's class attribute and returns it.
+ *
+ * @param {Node} node
+ * @returns {String}
+ */
+ orderClass: function (node) {
+ var className = node.getAttribute && node.getAttribute('class');
+ if (!className) {
+ return null;
+ }
+ className = className.replace(this.getRegex('spaceOrNewline', 'g'), ' ')
+ .replace(this.getRegex('startAndEndSpace', 'g'), '')
+ .replace(this.getRegex('space', 'g'), ' ');
+ className = className.replace('o_default_snippet_text', '')
+ .replace('o_checked', '');
+ if (!className.length) {
+ node.removeAttribute("class");
+ return null;
+ }
+ className = className.split(" ");
+ className.sort();
+ className = className.join(" ");
+ node.setAttribute('class', className);
+ return className;
+ },
+ /**
+ * Reorders the styles in the node's style attributes and returns it.
+ *
+ * @param {Node} node
+ * @returns {String}
+ */
+ orderStyle: function (node) {
+ var style = node.getAttribute('style');
+ if (!style) {
+ return null;
+ }
+ style = style.replace(this.getRegex('spaceOrNewline'), ' ')
+ .replace(this.getRegex('startAndEndSemicolon', 'g'), '')
+ .replace(this.getRegex('semicolon', 'g'), ';');
+ if (!style.length) {
+ node.removeAttribute("style");
+ return null;
+ }
+ style = style.split(";");
+ style.sort();
+ style = style.join("; ") + ";";
+ node.setAttribute('style', style);
+ return style;
+ },
+ /**
+ * Returns the path from the editable node to the given node.
+ *
+ * @param {Node} node
+ * @returns {Object[]} list of objects (tagName, offset)
+ */
+ path: function (node) {
+ var path = [];
+ while (node && node !== this.editable) {
+ var tagName = node.tagName;
+ path.unshift({
+ tagName: tagName,
+ offset: _.filter(node.parentNode.childNodes, function (node) {
+ return node.tagName === tagName;
+ }).indexOf(node),
+ });
+ node = node.parentNode;
+ }
+ return path;
+ },
+ /**
+ * Removes all attributes without a value from the given node.
+ *
+ * @param {Node} node
+ * @returns {Node}
+ */
+ removeBlankAttrs: function (node) {
+ _.each([].slice.call(node.attributes), function (attr) {
+ if (!attr.value) {
+ node.removeAttribute(attr.name);
+ }
+ });
+ return node;
+ },
+ /**
+ * Remove a node's direct blank siblings, if any.
+ * Eg: Text<i></i>Node<b></b>Text => TextNodeText
+ *
+ * @param {Node} node
+ */
+ removeBlankSiblings: function (node) {
+ var isAfterBlank = node.previousSibling && this.isBlankNode(node.previousSibling);
+ if (isAfterBlank) {
+ $(node.previousSibling).remove();
+ }
+ var isBeforeBlank = node.nextSibling && this.isBlankNode(node.nextSibling)
+ if (isBeforeBlank) {
+ $(node.nextSibling).remove();
+ }
+ },
+ /**
+ * Removes the block target and joins its siblings.
+ *
+ * @param {Node} target
+ * @param {Boolean} doNotInsertP true to NOT fill an empty unbreakable with a p element.
+ * @returns {Object} {node, offset}
+ */
+ removeBlockNode: function (target, doNotInsertP) {
+ var self = this;
+ var check = function (point) {
+ if (point.node === target) {
+ return false;
+ }
+ return !point.node || self.options.isEditableNode(point.node) &&
+ (point.node.tagName === "BR" || self.isVisibleText(point.node));
+ };
+ var parent = target.parentNode;
+ var offset = [].indexOf.call(parent.childNodes, target);
+ var deleteEdge = 'next';
+ var point = dom.prevPointUntil(this.makePoint(target, 0), check);
+ if (!point || !point.node) {
+ deleteEdge = 'prev';
+ point = dom.nextPointUntil(this.makePoint(target, 0), check);
+ }
+
+ $(target).remove();
+
+ if (
+ point && (deleteEdge === 'prev' && point.offset) ||
+ deleteEdge === 'next' && point.offset === dom.nodeLength(point.node)
+ ) {
+ point = this.deleteEdge(point.node, deleteEdge) || point;
+ }
+
+ $(parent).contents().filter(function () {
+ return dom.isText(this) && self.getRegexBlank({
+ atLeastOne: true,
+ invisible: true,
+ }).test(this.textContent);
+ }).remove();
+
+ var br;
+ if (parent.innerHTML === '') {
+ br = this.document.createElement('br');
+ $(parent).append(br);
+ point = this.makePoint(parent, 0);
+ }
+
+ if (!doNotInsertP && this.getRegexBlank({
+ space: true,
+ invisible: true,
+ }).test(parent.innerHTML)) {
+ br = this.document.createElement('br');
+ if (this.options.isUnbreakableNode(parent) && parent.tagName !== "TD") {
+ var p = this.document.createElement('p');
+ $(p).append(br);
+ $(parent).append(p);
+ } else {
+ $(parent).append(br);
+ }
+ point = this.makePoint(br.parentNode, 0);
+ }
+
+ if (point && point.node.tagName === "BR" && point.node.parentNode) {
+ point = {
+ node: point.node.parentNode,
+ offset: [].indexOf.call(point.node.parentNode.childNodes, point.node),
+ };
+ }
+
+ return point || this.makePoint(parent, offset);
+ },
+ /**
+ * Removes the empty inline nodes around the point, and joins its siblings.
+ *
+ * @param {Object} point {node, offset}
+ * @returns {Object} {node, offset}
+ */
+ removeEmptyInlineNodes: function (point) {
+ var node = point.node;
+ if (!point.node.tagName && !point.node.textContent.length) {
+ node = node.parentNode;
+ if ($(node).hasClass('o_default_snippet_text')) {
+ // for default snippet value
+ return point;
+ }
+ }
+ var prev;
+ var next;
+ while (
+ node.tagName !== 'BR' &&
+ (node.tagName ? node.innerHTML : node.textContent) === '' &&
+ !this.isNodeBlockType(node) &&
+ this.options.isEditableNode(node.parentNode) &&
+ (!node.attributes || !node.attributes.contenteditable) &&
+ !dom.isMedia(node)
+ ) {
+ prev = node.previousSibling;
+ next = node.nextSibling;
+ point = {
+ node: node.parentNode,
+ offset: [].indexOf.call(node.parentNode.childNodes, node)
+ };
+ $(node).remove();
+ node = point.node;
+ }
+ if (next && !next.tagName) {
+ if (/^\s+[^\s<]/.test(next.textContent)) {
+ next.textContent = next.textContent.replace(this.getRegex('startSpace'), '\u00A0');
+ }
+ }
+ if (prev) {
+ if (!prev.tagName) {
+ if (/[^\s>]\s+$/.test(prev.textContent)) {
+ prev.textContent = prev.textContent.replace(this.getRegex('endSpace'), ' ');
+ }
+ }
+ point = this.makePoint(prev, dom.nodeLength(prev));
+ }
+ return point;
+ },
+ /**
+ * Removes any amount of leading/trailing breakable space from a text node.
+ * Returns how many characters were removed at the start
+ * and at the end of the text node.
+ *
+ * @param {Node} textNode
+ * @param {Boolean} secureExtremeties (defaults to true)
+ * @returns {Object} removed {start, end}
+ */
+ removeExtremeBreakableSpace: function (textNode, secureExtremeties) {
+ if (arguments.length === 1) {
+ secureExtremeties = true;
+ }
+ if (secureExtremeties) {
+ this.secureExtremeSingleSpace(textNode);
+ }
+ var removed = {
+ start: 0,
+ end: 0,
+ };
+ textNode.textContent = textNode.textContent.replace(this.getRegex('startNotChar'), function (toRemove) {
+ removed.start = toRemove.length;
+ return '';
+ });
+ textNode.textContent = textNode.textContent.replace(this.getRegex('endNotChar'), function (toRemove) {
+ removed.end = toRemove.length;
+ return '';
+ });
+ return removed;
+ },
+ /**
+ * Makes the leading/trailing single space of a node non breakable (nbsp).
+ *
+ * @param {Node} node
+ */
+ secureExtremeSingleSpace: function (node) {
+ if (this.getRegex('endSingleSpace').test(node.textContent)) {
+ // if the text ends with a single space, make it insecable
+ node.textContent = node.textContent.substr(0, node.textContent.length - 1) + '\u00A0';
+ }
+ if (this.getRegex('startSingleSpace').test(node.textContent)) {
+ // if the text starts with a single space, make it insecable
+ node.textContent = '\u00A0' + node.textContent.substr(1, node.textContent.length);
+ }
+ },
+ /**
+ * Skips points to ignore (generally for trying to figure out if edge point).
+ * Returns the resulting point.
+ *
+ * @param {Object} point
+ * @param {String} direction ('prev' or 'next')
+ * @param {function} pred (extra condition to stop at)
+ * @param {Object} options
+ * @param {Boolean} options.noSkipBlankText true to not skip blank text
+ * @param {Boolean} options.noSkipSingleBRs true to not skip single BRs
+ * @param {Boolean} options.noSkipExtremeBreakableSpace true to not skip leading/trailing breakable space
+ * @param {Boolean} options.noSkipParent true to not skip to leaf nodes or offset 0
+ * @param {Boolean} options.noSkipSibling true to not skip if on edge and sibling is skippable
+ * @returns {Object} {node, offset}
+ */
+ skipNodes: function (point, direction, pred, options) {
+ var self = this;
+ if (arguments.length === 3 && !_.isFunction(arguments[2])) {
+ // allow for passing options and no pred function
+ options = _.clone(pred);
+ pred = null;
+ }
+ options = options || {};
+ return dom[direction + 'PointUntil'](point, function (pt) {
+ return !self.isSkippable(pt, direction, options) || pred && pred(pt);
+ });
+ },
+ /**
+ * Split the DOM tree at the node's start and end points.
+ *
+ * @param {Node} node
+ */
+ splitAtNodeEnds: function (node) {
+ if (!node.parentNode) {
+ return;
+ }
+ var startPoint = this.makePoint(node, 0);
+ var endPoint = this.makePoint(node, dom.nodeLength(node));
+ var splitOptions = {
+ isSkipPaddingBlankHTML: true,
+ };
+ this.splitTree(node.parentNode, startPoint, splitOptions);
+ this.splitTree(node.parentNode, endPoint, splitOptions);
+ // Splitting at ends may create blank nodes (because of dom.splitTree) so let's clean it up:
+ this.removeBlankSiblings(node.parentNode);
+ },
+ /**
+ * Split the DOM tree at the point
+ *
+ * @param {Node} root - split root
+ * @param {BoundaryPoint} point {node, offset}
+ * @param {Object} [options]
+ * @param {Boolean} [options.nextText] - default: false
+ * @param {Boolean} [options.isSkipPaddingBlankHTML] - default: false
+ * @param {Boolean} [options.isNotSplitEdgePoint] - default: false
+ * @returns {Node} right node of boundary point
+ */
+ splitTree: function (root, point, options) {
+ var nextText;
+ if (options && options.nextText && !point.node.tagName) {
+ nextText = point.node.splitText(point.offset);
+ }
+ var emptyText = false;
+ if (!point.node.tagName && point.node.textContent === "") {
+ emptyText = true;
+ point.node.textContent = '\u200B';
+ point.offset = 1;
+ }
+ var next = dom.splitTree(root, point, options);
+ if (emptyText) {
+ point.node.textContent = '';
+ }
+ var result = nextText || next || point.node;
+ var att = nextText ? 'textContent' : 'innerHTML';
+ if (/^\s+([^\s<])/.test(result[att])) {
+ result[att] = result[att].replace(this.getRegex('startSpace'), '\u00A0');
+ }
+ return result;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _insertTextNodeInEditableArea: function (range, text) {
+ // try to insert the text node in editable area
+ var textNode = this.document.createTextNode(text);
+ if (this.options.isEditableNode(range.sc) && $(range.sc).closest('[contenteditable]').attr('contenteditable') === 'true') {
+ if (dom.isText(range.sc) && this.isVisibleText(range.sc)) {
+ // If range is on visible text: split the text at offset and insert the text node
+ range.sc.splitText(range.so);
+ range.so = range.eo = dom.nodeLength(range.sc);
+ $(range.sc).after(textNode);
+ } else if (dom.isBR(range.sc)) {
+ if (range.sc.nextSibling && this.isVisibleText(range.sc.nextSibling)) {
+ $(range.sc).before(textNode);
+ } else {
+ $(range.sc).replaceWith(textNode);
+ }
+ } else if (dom.isVoid(range.sc)) {
+ $(range.sc).before(textNode);
+ } else if (range.sc.childNodes[range.so]) {
+ var node = range.sc.childNodes[range.so];
+ if (dom.isBR(node)) {
+ $(node).replaceWith(textNode);
+ } else {
+ $(node).before(textNode);
+ }
+ } else if (dom.isText(range.sc)) {
+ $(range.sc).after(textNode);
+ } else if (this.options.isUnbreakableNode(range.sc)) {
+ $(range.sc).append(textNode);
+ }
+ }
+ if (!textNode.parentNode && this.options.isEditableNode(range.sc.parentNode)) {
+ $(range.sc).before(textNode);
+ }
+
+ return textNode.parentNode && textNode;
+ },
+ /**
+ * Perform operations that are necessary after the insertion of a visible character:
+ * adapt range for the presence of zero-width characters, move out of media, rerange.
+ *
+ * @private
+ */
+ _removeInvisibleChar: function (range) {
+ if (range.sc.tagName || dom.ancestor(range.sc, dom.isAnchor)) {
+ return true;
+ }
+ var needReselect = false;
+ var fake = range.sc.parentNode;
+ if ((fake.className || '').indexOf('o_fake_editable') !== -1 && dom.isMedia(fake)) {
+ var $media = $(fake.parentNode);
+ $media[fake.previousElementSibling ? 'after' : 'before'](fake.firstChild);
+ needReselect = true;
+ }
+ if (range.sc.textContent.slice(range.so - 2, range.so - 1) === '\u200B') {
+ range.sc.textContent = range.sc.textContent.slice(0, range.so - 2) + range.sc.textContent.slice(range.so - 1);
+ range.so = range.eo = range.so - 1;
+ needReselect = true;
+ }
+ if (range.sc.textContent.slice(range.so, range.so + 1) === '\u200B') {
+ range.sc.textContent = range.sc.textContent.slice(0, range.so) + range.sc.textContent.slice(range.so + 1);
+ needReselect = true;
+ }
+ if (needReselect) {
+ range = range.normalize();
+ }
+ return range;
+ },
+ _standardizeRangeOnEdge: function (range) {
+ var self = this;
+
+ var invisible = this.document.createTextNode('\u200B');
+
+ if (dom.isText(range.sc) && !this.isVisibleText(range.sc) && range.sc.nextSibling) {
+ var firstLeafOfNext = this.firstLeaf(range.sc.nextSibling);
+ range = this.context.invoke('editor.setRange', firstLeafOfNext, 0);
+ }
+
+ // Create empty text node to have a range into the node
+ if (range.sc.tagName && !dom.isVoid(range.sc) && !range.sc.childNodes[range.so]) {
+ $(range.sc).append(invisible);
+ range = this.context.invoke('editor.setRange', invisible, 0);
+ }
+
+ // On left edge of non-empty element: move before
+ var siblings = range.sc.parentNode && range.sc.parentNode.childNodes;
+ var isInEmptyElem = !siblings || !siblings.length || _.all(siblings, this.isBlankNode.bind(this));
+ if (
+ !range.so && !isInEmptyElem &&
+ !(range.sc.previousSibling && range.sc.previousSibling.tagName === "BR") &&
+ !this.options.isEditableNode(range.sc)
+ ) {
+ var point = range.getStartPoint();
+ var newPoint = dom.prevPointUntil(point, function (pt) {
+ return pt.node !== range.sc && !pt.node.tagName && !self.isBlankText(pt.node);
+ });
+ if (!newPoint || this.firstBlockAncestor(newPoint.node) !== this.firstBlockAncestor(point.node)) {
+ range = this.context.invoke('editor.setRange', point.node, point.offset);
+ } else {
+ range = this.context.invoke('editor.setRange', newPoint.node, newPoint.offset);
+ }
+ }
+
+ return range;
+ },
+ _wrapTextWithP: function (textNode) {
+ var self = this;
+ var isFormatNode = dom.ancestor(textNode, this.isFormatNode.bind(this));
+ if (!isFormatNode) {
+ var hasInlineParent = dom.ancestor(textNode.parentNode, function (node) {
+ return !self.isNodeBlockType(node);
+ });
+ if (
+ !hasInlineParent &&
+ (textNode.tagName ||
+ !(textNode.previousSibling && this.isVisibleText(textNode.previousSibling) ||
+ textNode.nextSibling && this.isVisibleText(textNode.nextSibling))
+ )
+ ) {
+ var blankP = this.document.createElement('p');
+ $(textNode).after(blankP);
+ $(blankP).prepend(textNode);
+ }
+ }
+ },
+});
+
+registry.add('HelperPlugin', HelperPlugin);
+
+return HelperPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/hint.js b/addons/web_editor/static/src/js/wysiwyg/plugin/hint.js
new file mode 100644
index 00000000000..708ac89333f
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/hint.js
@@ -0,0 +1,139 @@
+odoo.define('web_editor.wysiwyg.plugin.hint', function (require) {
+'use strict';
+
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var dom = $.summernote.dom;
+
+
+var HintPlugin = Plugins.hintPopover.extend({
+ init: function (context) {
+ context.options.hint = (context.options.hint || []).concat(this._hints());
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public summernote module API
+ //--------------------------------------------------------------------------
+
+ /**
+ * Replace the current hint.
+ */
+ replace: function () {
+ var self = this;
+ var $item = this.$content.find('.note-hint-item.active');
+ if ($item.length) {
+ this.lastWordRange.select();
+ this.context.invoke('HelperPlugin.deleteSelection');
+ var range = this.context.invoke('editor.createRange');
+
+ this.nodeFromItem($item).each(function () {
+ $(range.sc).after(this);
+ range = self.context.invoke('editor.setRange', this, dom.nodeLength(this));
+ });
+ range.select();
+ this.context.invoke('editor.saveRange');
+ this.lastWordRange = null;
+ this.hide();
+ this.context.triggerEvent('change', this.$editable.html(), this.$editable[0]);
+ this.context.invoke('editor.focus');
+ }
+ },
+ /**
+ * @param {JQueryEvent} e
+ */
+ handleKeyup: function (e) {
+ var self = this;
+ if ([13, 38, 40].indexOf(e.keyCode) === -1) { // enter, up, down
+ var wordRange = this.context.invoke('editor.createRange');
+ var keyword_1 = wordRange.sc.textContent.slice(0, wordRange.so);
+ if (this.hints.length && keyword_1) {
+ this.$content.empty();
+ this.$popover.hide();
+ this.lastWordRange = wordRange;
+ var hasMatch = false;
+
+ // test all hints
+ this.hints.forEach(function (hint, idx) {
+ var match = keyword_1.match(hint.match);
+ if (match) {
+ hasMatch = true;
+ wordRange.so = wordRange.eo - match[0].length;
+ self.createGroup(idx, match[0]).appendTo(self.$content);
+ }
+ });
+ if (!hasMatch) {
+ return;
+ }
+
+ // select first .note-hint-item
+ this.$content.find('.note-hint-item:first').addClass('active');
+
+ // set position for popover after group is created
+ var rect = wordRange.getClientRects()[0];
+ var bnd = {
+ top: rect.top + $(this.document).scrollTop(),
+ left: rect.left + $(this.document).scrollLeft(),
+ };
+ this.$popover.css({
+ left: bnd.left,
+ top: bnd.top + (this.direction === 'top' ? -this.$popover.outerHeight() : (rect.bottom - rect.top)) - 5,
+ });
+ } else {
+ this.hide();
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get hint objects.
+ *
+ * @private
+ * @returns {Object[]} hints
+ */
+ _hints: function () {
+ var self = this;
+ return [{
+ className: 'o_hint_partner',
+ match: /\B@(\w+(\s\w*)?)$/,
+ search: function (keyword, callback) {
+ self._rpc({
+ model: 'res.partner',
+ method: "search_read",
+ fields: ['id', 'name', 'email'],
+ domain: [
+ ['name', 'ilike', keyword],
+ ],
+ limit: 10,
+ }).then(callback);
+ },
+ template: function (partner) {
+ return partner.name + (partner.email ? ' <i style="color: #999;">(' + partner.email + ')</i>' : '');
+ },
+ content: function (item) {
+ return $(self.document.createTextNode('@' + item.name + '\u00A0'));
+ },
+ },
+ {
+ className: 'fa',
+ match: /:([\-+\w]+)$/,
+ search: function () {},
+ template: function () {
+ return '<span class="fa fa-star">\u200B</span>';
+ },
+ content: function () {}
+ },
+ ];
+ },
+});
+
+registry.add('hintPopover', null);
+
+return HintPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/history.js b/addons/web_editor/static/src/js/wysiwyg/plugin/history.js
new file mode 100644
index 00000000000..a60ab41523b
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/history.js
@@ -0,0 +1,81 @@
+odoo.define('web_editor.wysiwyg.plugin.history', function (require) {
+'use strict';
+
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+
+var HistoryPlugin = Plugins.history.extend({
+ /**
+ * Apply a snapshot.
+ *
+ * @override
+ */
+ applySnapshot: function () {
+ this.context.invoke('MediaPlugin.hidePopovers');
+ try {
+ this._super.apply(this, arguments);
+ } catch (e) {
+ console.error(e);
+ }
+ this.context.invoke('editor.focus');
+ },
+ /**
+ * Clear the history.
+ *
+ * @override
+ */
+ clear: function () {
+ this.stack = [];
+ this.stackOffset = -1;
+ this.recordUndo();
+ },
+ /**
+ * Get the current history stack and the current offset.
+ *
+ * @returns {Object} {stack: Object, stackOffset: Integer}
+ */
+ getHistoryStep: function () {
+ return {
+ stack: this.stack,
+ stackOffset: this.stackOffset,
+ };
+ },
+ /**
+ * Prevent errors with first snapshot.
+ *
+ * @override
+ */
+ makeSnapshot: function () {
+ var rng = $.summernote.range.create(this.editable);
+ var snapshot = this._super();
+ if (rng.sc === this.editable || $(rng.sc).has(this.editable).length) {
+ snapshot.bookmark.s.path = snapshot.bookmark.e.path = [0];
+ snapshot.bookmark.s.offset = snapshot.bookmark.e.offset = 0;
+ }
+ return snapshot;
+ },
+ /**
+ * @override
+ */
+ recordUndo: function () {
+ if (!this.stack[this.stackOffset] || this.$editable.html() !== this.stack[this.stackOffset].contents) {
+ this._super();
+ }
+ },
+ /**
+ * @override
+ */
+ undo: function () {
+ if (this.stackOffset > 0) {
+ this.stackOffset--;
+ }
+ this.applySnapshot(this.stack[this.stackOffset]);
+ },
+});
+
+registry.add('HistoryPlugin', HistoryPlugin);
+
+return HistoryPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js b/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js
new file mode 100644
index 00000000000..f06f8e91229
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js
@@ -0,0 +1,1182 @@
+odoo.define('web_editor.wysiwyg.plugin.keyboard', function (require) {
+'use strict';
+
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var dom = $.summernote.dom;
+dom.isAnchor = function (node) {
+ return (node.tagName === 'A' || node.tagName === 'BUTTON' || $(node).hasClass('btn')) &&
+ !$(node).hasClass('fa') && !$(node).hasClass('o_image');
+};
+
+var KeyboardPlugin = AbstractPlugin.extend({
+ events: {
+ 'summernote.keydown': '_onKeydown',
+ 'DOMNodeInserted .note-editable': '_removeGarbageSpans',
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Perform various DOM and range manipulations after a deletion:
+ * - Rerange out of BR elements
+ * - Clean the DOM at current range position
+ *
+ * @see _handleDeletion
+ *
+ * @private
+ * @param {Object} range
+ * @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret
+ * @returns {Object} range
+ */
+ _afterDeletion: function (range, direction) {
+ range = direction === 'prev' ? this._insertInvisibleCharAfterSingleBR(range) : range;
+ range = this._rerangeOutOfBR(range, direction);
+ range = this._cleanRangeAfterDeletion(range);
+ return range;
+ },
+ /**
+ * Perform operations that are necessary after the insertion of a visible character:
+ * - Adapt range for the presence of zero-width characters
+ * - Move out of media
+ * - Rerange
+ *
+ * @private
+ */
+ _afterVisibleChar: function () {
+ var range = this.context.invoke('editor.createRange');
+ if (range.sc.tagName || dom.ancestor(range.sc, dom.isAnchor)) {
+ return true;
+ }
+ var needReselect = false;
+ var fake = range.sc.parentNode;
+ if ((fake.className || '').indexOf('o_fake_editable') !== -1 && dom.isMedia(fake)) {
+ var $media = $(fake.parentNode);
+ $media[fake.previousElementSibling ? 'after' : 'before'](fake.firstChild);
+ needReselect = true;
+ }
+ if (range.sc.textContent.slice(range.so - 2, range.so - 1) === '\u200B') {
+ range.sc.textContent = range.sc.textContent.slice(0, range.so - 2) + range.sc.textContent.slice(range.so - 1);
+ range.so = range.eo = range.so - 1;
+ needReselect = true;
+ }
+ if (range.sc.textContent.slice(range.so, range.so + 1) === '\u200B') {
+ range.sc.textContent = range.sc.textContent.slice(0, range.so) + range.sc.textContent.slice(range.so + 1);
+ needReselect = true;
+ }
+ if (needReselect) {
+ range.normalize().select();
+ }
+ },
+ /**
+ * Perform various DOM and range manipulations to prepare a deletion:
+ * - Rerange within the element targeted by the range
+ * - Slice the text content if necessary
+ * - Move before an invisible BR if necessary
+ * - Replace a media with an empty SPAN if necessary
+ * - Change the direction of deletion if necessary
+ * - Clean the DOM at range position if necessary
+ *
+ * @see _handleDeletion
+ *
+ * @private
+ * @param {Object} range
+ * @param {String('prev'|'next')} direction
+ * @param {Boolean} didDeleteNodes true if nodes were already deleted prior to this call
+ * @returns {Object} {didDeleteNodes: Boolean, range: Object, direction: String('prev'|next')}
+ */
+ _beforeDeletion: function (range, direction, didDeleteNodes) {
+ var res = {
+ range: range,
+ direction: direction,
+ didDeleteNodes: didDeleteNodes,
+ };
+
+ res.range = this._rerangeToOffsetChild(res.range, direction);
+ res.range = this._sliceAndRerangeBeforeDeletion(res.range);
+ res.range = direction === 'prev' ? this._moveBeforeInvisibleBR(res.range) : res.range;
+
+ if (dom.isMedia(res.range.sc)) {
+ var span = this._replaceMediaWithEmptySpan(res.range.sc);
+ res.range = this.context.invoke('editor.setRange', span, 0);
+ res.didDeleteNodes = true;
+ return res;
+ }
+
+ if (res.didDeleteNodes) {
+ res.direction = 'next';
+ return res;
+ }
+
+ res.range = this._cleanRangeBeforeDeletion(res.range, direction);
+
+ return res;
+ },
+ /**
+ * Clean the DOM at range position after a deletion:
+ * - Remove empty inline nodes
+ * - Fill the current node if it's empty
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Object} range
+ */
+ _cleanRangeAfterDeletion: function (range) {
+ var point = range.getStartPoint();
+
+ point = this.context.invoke('HelperPlugin.removeEmptyInlineNodes', point);
+ point = this.context.invoke('HelperPlugin.fillEmptyNode', point);
+ range = this.context.invoke('editor.setRange', point.node, point.offset);
+ return range;
+ },
+ /**
+ * Clean the DOM at range position:
+ * - Remove all previous zero-width characters
+ * - Remove leading/trailing breakable space
+ *
+ * @private
+ * @param {Object} range
+ * @param {String('prev'|'next')} direction
+ * @returns {Object} range
+ */
+ _cleanRangeBeforeDeletion: function (range, direction) {
+ if (direction === 'prev') {
+ this._removeAllPreviousInvisibleChars(range);
+ }
+ range = this._removeExtremeBreakableSpaceAndRerange(range);
+ return range;
+ },
+ /**
+ * Get information on the range in order to perform a deletion:
+ * - The point at which to delete, if any
+ * - Whether the node contains a block
+ * - The block to remove, if any
+ *
+ * @private
+ * @param {Object} range
+ * @param {String('prev'|'next')} direction
+ * @param {Boolean} wasOnStartOfBR true if the requested deletion started at
+ * the beginning of a BR element
+ * @returns {Object} {
+ * point: {false|Object},
+ * hasBlock: {Boolean},
+ * blockToRemove: {false|Node},
+ * }
+ */
+ _getDeleteInfo: function (range, direction, wasOnStartOfBR) {
+ var self = this;
+ var hasBlock = false;
+ var blockToRemove = false;
+ var method = direction === 'prev' ? 'prevPointUntil' : 'nextPointUntil';
+
+ var pt = range.getStartPoint();
+ pt = dom[method](pt, function (point) {
+ var isAtStartOfMedia = !point.offset && dom.isMedia(point.node);
+ var isBRorHR = point.node.tagName === 'BR' || point.node.tagName === 'HR';
+ var isRootBR = wasOnStartOfBR && point.node === range.sc;
+ var isOnRange = range.ec === point.node && range.eo === point.offset;
+
+ if (!point.offset && self.context.invoke('HelperPlugin.isNodeBlockType', point.node)) {
+ hasBlock = true;
+ if (blockToRemove) {
+ return true;
+ }
+ }
+
+ if (!blockToRemove && (isAtStartOfMedia || isBRorHR && !isRootBR)) {
+ blockToRemove = point.node;
+ return false;
+ }
+
+ if (isOnRange) {
+ return false;
+ }
+
+ return self._isDeletableNode(point.node);
+ });
+
+ return {
+ point: pt || false,
+ hasBlock: hasBlock,
+ blockToRemove: blockToRemove,
+ };
+ },
+ /**
+ * Handle deletion (BACKSPACE / DELETE).
+ *
+ * @private
+ * @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret
+ * @returns {Boolean} true if case handled
+ */
+ _handleDeletion: function (direction) {
+ var didDeleteNodes = this.context.invoke('HelperPlugin.deleteSelection')
+ var range = this.context.invoke('editor.createRange');
+ var wasOnStartOfBR = direction === 'prev' && !range.so && range.sc.tagName === 'BR';
+
+ var temp = this._beforeDeletion(range, direction, didDeleteNodes);
+ didDeleteNodes = temp.didDeleteNodes;
+ range = temp.range;
+ direction = temp.direction;
+
+ if (!didDeleteNodes) {
+ var newRange = this._performDeletion(range, direction, wasOnStartOfBR);
+ didDeleteNodes = newRange.so !== range.so || newRange.sc !== range.sc;
+ range = newRange;
+ }
+
+ range = this._afterDeletion(range, direction);
+
+ range = range.collapse(direction === 'prev').select();
+ this.editable.normalize();
+ return didDeleteNodes;
+ },
+ /**
+ * Handle ENTER.
+ *
+ * @private
+ * @returns {Boolean} true if case handled
+ */
+ _handleEnter: function () {
+ var self = this;
+ var range = this.context.invoke('editor.createRange');
+
+ var ancestor = dom.ancestor(range.sc, function (node) {
+ return dom.isLi(node) || self.options.isUnbreakableNode(node.parentNode) && node.parentNode !== self.editable ||
+ self.context.invoke('HelperPlugin.isNodeBlockType', node) && !dom.ancestor(node, dom.isLi);
+ });
+
+ if (
+ dom.isLi(ancestor) && !$(ancestor.parentNode).hasClass('list-group') &&
+ this.context.invoke('HelperPlugin.getRegexBlank', {
+ space: true,
+ newline: true,
+ }).test(ancestor.textContent) &&
+ $(ancestor).find('br').length <= 1 &&
+ !$(ancestor).find('.fa, img').length
+ ) {
+ // double enter in a list make oudent
+ this.context.invoke('BulletPlugin.outdent');
+ return true;
+ }
+
+ var btn = dom.ancestor(range.sc, function (n) {
+ return $(n).hasClass('btn');
+ });
+
+ var point = range.getStartPoint();
+
+ if (!point.node.tagName && this.options.isUnbreakableNode(point.node.parentNode)) {
+ return this._handleShiftEnter();
+ }
+
+ if (point.node.tagName && point.node.childNodes[point.offset] && point.node.childNodes[point.offset].tagName === "BR") {
+ point = dom.nextPoint(point);
+ }
+ if (point.node.tagName === "BR") {
+ point = dom.nextPoint(point);
+ }
+
+ var next = this.context.invoke('HelperPlugin.splitTree', ancestor, point, {
+ isSkipPaddingBlankHTML: !this.context.invoke('HelperPlugin.isNodeBlockType', point.node.parentNode) && !!point.node.parentNode.nextSibling
+ });
+ while (next.firstChild) {
+ next = next.firstChild;
+ }
+
+ // if there is no block in the split parents, then we add a br between the two node
+ var hasSplitBlock = false;
+ var node = next;
+ var lastChecked = node;
+ while (node && node !== ancestor && node !== this.editable) {
+ if (this.context.invoke('HelperPlugin.isNodeBlockType', node)) {
+ hasSplitBlock = true;
+ break;
+ }
+ lastChecked = node;
+ node = node.parentNode;
+ }
+ if (!hasSplitBlock && lastChecked.tagName) {
+ $(lastChecked).before(this.document.createElement('br'));
+ }
+
+ if (!next.tagName) {
+ this.context.invoke('HelperPlugin.secureExtremeSingleSpace', next);
+ }
+ if (next.tagName !== "BR" && next.innerHTML === "") {
+ next.innerHTML = '\u200B';
+ }
+ if (ancestor) {
+ var firstChild = this.context.invoke('HelperPlugin.firstLeaf', ancestor);
+ var lastChild = this.context.invoke('HelperPlugin.lastLeaf', ancestor);
+ if (this.context.invoke('HelperPlugin.isBlankNode', ancestor)) {
+ firstChild = dom.isText(firstChild) ? firstChild.parentNode : firstChild;
+ $(firstChild).contents().remove();
+ $(firstChild).append(this.document.createElement('br'));
+ }
+ if (lastChild.tagName === 'BR' && lastChild.previousSibling) {
+ $(lastChild).after(this.document.createTextNode('\u200B'));
+ }
+ }
+
+ // move to next editable area
+ point = this.context.invoke('HelperPlugin.makePoint', next, 0);
+ if (
+ (point.node.tagName && point.node.tagName !== 'BR') ||
+ !this.context.invoke('HelperPlugin.isVisibleText', point.node.textContent)
+ ) {
+ point = dom.nextPointUntil(point, function (pt) {
+ if (pt.node === point.node) {
+ return;
+ }
+ return (
+ pt.node.tagName === "BR" ||
+ self.context.invoke('HelperPlugin.isVisibleText', pt.node)
+ ) &&
+ self.options.isEditableNode(pt.node);
+ });
+ point = point || this.context.invoke('HelperPlugin.makePoint', next, 0);
+ if (point.node.tagName === "BR") {
+ point = dom.nextPoint(point);
+ }
+ }
+
+ if (!hasSplitBlock && !point.node.tagName) {
+ point.node.textContent = '\u200B' + point.node.textContent;
+ point.offset = 1;
+ }
+
+ // if the left part of the split node ends with a space, replace that space with nbsp
+ if (range.sc.textContent) {
+ var endSpace = this.context.invoke('HelperPlugin.getRegex', 'endSpace');
+ range.sc.textContent = range.sc.textContent.replace(endSpace,
+ function (trailingSpaces) {
+ return Array(trailingSpaces.length + 1).join('\u00A0');
+ }
+ );
+ }
+
+ // On buttons, we want to split the button and move to the beginning of it
+ if (btn) {
+ next = dom.ancestor(point.node, function (n) {
+ return $(n).hasClass('btn');
+ });
+
+ // Move carret to the new button
+ range = this.context.invoke('editor.setRange', next.firstChild, 0);
+ range.select();
+
+ // Force content in empty buttons, the carret can be moved there
+ this.context.invoke('LinkPopover.hide');
+ this.context.invoke('LinkPopover.fillEmptyLink', next, true);
+ this.context.invoke('LinkPopover.fillEmptyLink', btn, true);
+ } else {
+ range = this.context.invoke('editor.setRange', point.node, point.offset);
+ range.normalize().select();
+ }
+
+ return true;
+ },
+ /**
+ * Handle SHIFT+ENTER.
+ *
+ * @private
+ * @returns {Boolean} true if case handled
+ */
+ _handleShiftEnter: function () {
+ var range = this.context.invoke('editor.createRange');
+ var target = range.sc.childNodes[range.so] || range.sc;
+ var before;
+ if (target.tagName) {
+ if (target.tagName === "BR") {
+ before = target;
+ } else if (target === range.sc) {
+ if (range.so) {
+ before = range.sc.childNodes[range.so - 1];
+ } else {
+ before = this.document.createTextNode('');
+ $(range.sc).append(before);
+ }
+ }
+ } else {
+ before = target;
+ var after = target.splitText(target === range.sc ? range.so : 0);
+ if (
+ !after.nextSibling && after.textContent === '' &&
+ this.context.invoke('HelperPlugin.isNodeBlockType', after.parentNode)
+ ) {
+ after.textContent = '\u200B';
+ }
+ if (!after.tagName && (!after.previousSibling || after.previousSibling.tagName === "BR")) {
+ after.textContent = after.textContent.replace(startSpace, '\u00A0');
+ }
+ }
+
+ if (!before) {
+ return true;
+ }
+
+ var br = this.document.createElement('br');
+ $(before).after(br);
+ var next = this.context.invoke('HelperPlugin.makePoint', br, 0);
+ var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace');
+
+ if (!before.tagName) {
+ next = dom.nextPoint(next);
+ var nextNode = this.context.invoke('HelperPlugin.firstLeaf', next.node.childNodes[next.offset] || next.node);
+ if (!nextNode.tagName) {
+ next.node = nextNode;
+ next.offset = 0;
+ }
+ }
+
+ if (
+ next.node.tagName === "BR" && next.node.nextSibling &&
+ !next.node.nextSibling.tagName && !dom.ancestor(next.node, dom.isPre)
+ ) {
+ next.node.nextSibling.textContent = next.node.nextSibling.textContent.replace(startSpace, '\u00A0');
+ }
+ if (
+ !next.node.tagName &&
+ (!next.node.previousSibling || next.node.previousSibling.tagName === "BR") &&
+ !dom.ancestor(next.node, dom.isPre)
+ ) {
+ next.node.textContent = next.node.textContent.replace(startSpace, '\u00A0');
+ }
+
+ range = this.context.invoke('editor.setRange', next.node, next.offset);
+ range.select();
+
+ return true;
+ },
+ /**
+ * Insert a zero-width character after a BR if the range is
+ * at the beginning of an invisible text node
+ * and after said single BR element.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Object} range
+ */
+ _insertInvisibleCharAfterSingleBR: function (range) {
+ if (this._isAtStartOfInvisibleText(range) && this._isAfterSingleBR(range.sc)) {
+ var invisibleChar = this.document.createTextNode('\u200B');
+ $(range.sc.previousSibling).after(invisibleChar);
+ range = this.context.invoke('editor.setRange', invisibleChar, 1);
+ }
+ return range;
+ },
+ /**
+ * Return true if the node comes after a BR element.
+ *
+ * @private
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ _isAfterBR: function (node) {
+ return node.previousSibling && node.previousSibling.tagName === 'BR';
+ },
+ /**
+ * Return true if the range if positioned after a BR element that doesn't visually
+ * show a new line in the DOM: a BR in an element that has only a BR, or text then a BR.
+ * eg: <p><br></p> or <p>text<br></p>
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Boolean}
+ */
+ _isAfterInvisibleBR: function (range) {
+ return this._isAfterOnlyBR(range) || this._isAfterOnlyTextThenBR(range);
+ },
+ /**
+ * Return true if the range is positioned on a text node, after an zero-width character.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Boolean}
+ */
+ _isAfterInvisibleChar: function (range) {
+ return !range.sc.tagName && range.so && range.sc.textContent[range.so - 1] === '\u200B';
+ },
+ /**
+ * Return true if the range is positioned on a text node, after an leading zero-width character.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Boolean}
+ */
+ _isAfterLeadingInvisibleChar: function (range) {
+ return !range.sc.tagName && range.so === 1 && range.sc.textContent[0] === '\u200B';
+ },
+ /**
+ * Return true if the range if positioned after a BR element in a node that has only a BR.
+ * eg: <p><br></p>
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Boolean}
+ */
+ _isAfterOnlyBR: function (range) {
+ return this._hasOnlyBR(range.sc) && range.so === 1;
+ },
+ /**
+ * Return true if the node has for only element child a BR element.
+ *
+ * @private
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ _hasOnlyBR: function (node) {
+ return node.childElementCount === 1 && node.firstChild.tagName === 'BR';
+ },
+ /**
+ * Return true if the range if positioned after a BR element in a node that has only text
+ * and ends with a BR.
+ * eg: <p>text<br></p>
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Boolean}
+ */
+ _isAfterOnlyTextThenBR: function (range) {
+ var hasTrailingBR = range.sc.lastChild && range.sc.lastChild.tagName === 'BR';
+ if (!hasTrailingBR) {
+ return false;
+ }
+ var hasOnlyTextThenBR = _.all(range.sc.childNodes, function (n) {
+ return dom.isText(n) || n === range.sc.lastChild;
+ });
+ var isAfterTrailingBR = range.so === dom.nodeLength(range.sc);
+ return hasOnlyTextThenBR && isAfterTrailingBR;
+ },
+ /**
+ * Return true if the node is after a single BR.
+ *
+ * @private
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ _isAfterSingleBR: function (node) {
+ var isPreviousAfterBR = node.previousSibling && this._isAfterBR(node.previousSibling);
+ return this._isAfterBR(node) && !isPreviousAfterBR;
+ },
+ /**
+ * Return true if the node comes after two BR elements.
+ *
+ * @private
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ _isAfterTwoBRs: function (node) {
+ var isAfterBR = this._isAfterBR(node);
+ var isPreviousSiblingAfterBR = node.previousSibling && this._isAfterBR(node.previousSibling);
+ return isAfterBR && isPreviousSiblingAfterBR;
+ },
+ /**
+ * Return true if the range is positioned at the start of an invisible text node.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Boolean}
+ */
+ _isAtStartOfInvisibleText: function (range) {
+ return !range.so && dom.isText(range.sc) && !this.context.invoke('HelperPlugin.isVisibleText', range.sc);
+ },
+ /**
+ * Return true if the range is positioned on a text node, before a trailing zero-width character.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Boolean}
+ */
+ _isBeforeTrailingInvisibleChar: function (range) {
+ var isBeforeLastCharOfText = !range.sc.tagName && range.so === dom.nodeLength(range.sc) - 1;
+ var isLastCharInvisible = range.sc.textContent.slice(range.so) === '\u200B';
+ return isBeforeLastCharOfText && isLastCharInvisible;
+ },
+ /**
+ * Return true if the node is deletable.
+ *
+ * @private
+ * @param {Node} node
+ * @return {Boolean}
+ */
+ _isDeletableNode: function (node) {
+ var isVisibleText = this.context.invoke('HelperPlugin.isVisibleText', node);
+ var isMedia = dom.isMedia(node);
+ var isBR = node.tagName === 'BR';
+ var isEditable = this.options.isEditableNode(node);
+ return isEditable && (isVisibleText || isMedia || isBR);
+ },
+ /**
+ * Return true if the range is positioned on an edge to delete, depending on the given direction.
+ *
+ * @private
+ * @param {Object} range
+ * @param {String('prev'|'next')} direction
+ */
+ _isOnEdgeToDelete: function (range, direction) {
+ var isOnBR = range.sc.tagName === 'BR';
+ var parentHasOnlyBR = range.sc.parentNode && range.sc.parentNode.innerHTML.trim() === "<br>";
+ var isOnDirEdge;
+ if (direction === 'next') {
+ isOnDirEdge = range.so === dom.nodeLength(range.sc);
+ } else {
+ isOnDirEdge = range.so === 0;
+ }
+ return (!isOnBR || parentHasOnlyBR) && isOnDirEdge;
+ },
+ /**
+ * Move the range before a BR if that BR doesn't visually show a new line in the DOM.
+ * Return the new range.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Object} range
+ */
+ _moveBeforeInvisibleBR: function (range) {
+ if (this._isAfterInvisibleBR(range)) {
+ range.so -= 1;
+ }
+ return range;
+ },
+ /**
+ * Perform a deletion in the given direction.
+ * Note: This is where the actual deletion takes place.
+ * It should be preceded by _beforeDeletion and
+ * followed by _afterDeletion.
+ *
+ * @see _handleDeletion
+ *
+ * @private
+ * @param {Object} range
+ * @param {String('prev'|'next')} direction 'prev' to delete BEFORE the carret
+ * @param {Boolean} wasOnStartOfBR true if the requested deletion started at
+ * the beginning of a BR element
+ * @returns {Object} range
+ */
+ _performDeletion: function (range, direction, wasOnStartOfBR) {
+ var didDeleteNodes = false;
+ if (this._isOnEdgeToDelete(range, direction)) {
+ var rest = this.context.invoke('HelperPlugin.deleteEdge', range.sc, direction);
+ didDeleteNodes = !!rest;
+ if (didDeleteNodes) {
+ range = this.context.invoke('editor.setRange', rest.node, rest.offset);
+ return range;
+ }
+ }
+
+ var deleteInfo = this._getDeleteInfo(range, direction, wasOnStartOfBR);
+
+ if (!deleteInfo.point) {
+ return range;
+ }
+
+ var point = deleteInfo.point;
+ var blockToRemove = deleteInfo.blockToRemove;
+ var hasBlock = deleteInfo.hasBlock;
+
+ var isLonelyBR = blockToRemove && blockToRemove.tagName === 'BR' && this._hasOnlyBR(blockToRemove.parentNode);
+ var isHR = blockToRemove && blockToRemove.tagName === "HR";
+
+ if (blockToRemove && !isLonelyBR) {
+ $(blockToRemove).remove();
+ point = isHR ? this.context.invoke('HelperPlugin.deleteEdge', range.sc, direction) : point;
+ didDeleteNodes = true;
+ } else if (!hasBlock) {
+ var isAtEndOfNode = point.offset === dom.nodeLength(point.node);
+ var shouldMove = isAtEndOfNode || direction === 'next' && point.offset;
+
+ point.offset = shouldMove ? point.offset - 1 : point.offset;
+ point.node = this._removeCharAtOffset(point);
+ didDeleteNodes = true;
+
+ var isInPre = !!dom.ancestor(range.sc, dom.isPre);
+ if (!isInPre) {
+ this.context.invoke('HelperPlugin.secureExtremeSingleSpace', point.node);
+ }
+
+ if (direction === 'prev' && !point.offset && !this._isAfterBR(point.node)) {
+ point.node = this._replaceLeadingSpaceWithSingleNBSP(point.node);
+ }
+ }
+
+ if (didDeleteNodes) {
+ range = this.context.invoke('editor.setRange', point.node, point.offset);
+ }
+ return range;
+ },
+ /**
+ * Prevent the appearance of a text node with the editable DIV as direct parent:
+ * wrap it in a p element.
+ *
+ * @private
+ */
+ _preventTextInEditableDiv: function () {
+ var range = this.context.invoke('editor.createRange');
+ while (
+ dom.isText(this.editable.firstChild) &&
+ !this.context.invoke('HelperPlugin.isVisibleText', this.editable.firstChild)
+ ) {
+ var node = this.editable.firstChild;
+ if (node && node.parentNode) {
+ node.parentNode.removeChild(node);
+ }
+ }
+ var editableIsEmpty = !this.editable.childNodes.length;
+ if (editableIsEmpty) {
+ var p = this.document.createElement('p');
+ p.innerHTML = '<br>';
+ this.editable.appendChild(p);
+ range = this.context.invoke('editor.setRange', p, 0);
+ } else if (this.context.invoke('HelperPlugin.isBlankNode', this.editable.firstChild) && !range.sc.parentNode) {
+ this.editable.firstChild.innerHTML = '<br/>';
+ range = this.context.invoke('editor.setRange', this.editable.firstChild, 0);
+ }
+
+ range.select();
+ },
+ /**
+ * Remove all invisible chars before the current range, that are adjacent to it,
+ * then rerange.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Object} range
+ */
+ _removeAllPreviousInvisibleChars: function (range) {
+ while (this._isAfterInvisibleChar(range)) {
+ var text = range.sc.textContent;
+ range.sc.textContent = text.slice(0, range.so - 1) + text.slice(range.so, text.length);
+ range.so -= 1;
+ }
+ return range;
+ },
+ /**
+ * Remove a char from a point's text node, at the point's offset.
+ *
+ * @private
+ * @param {Object} point
+ * @returns {Node}
+ */
+ _removeCharAtOffset: function (point) {
+ var text = point.node.textContent;
+ var startToOffset = text.slice(0, point.offset);
+ var offsetToEnd = text.slice(point.offset + 1);
+ point.node.textContent = startToOffset + offsetToEnd;
+ return point.node;
+ },
+ /**
+ * Remove any amount of leading/trailing breakable space at range position.
+ * Then move the range and return it.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Object} range
+ */
+ _removeExtremeBreakableSpaceAndRerange: function (range) {
+ var isInPre = !!dom.ancestor(range.sc, dom.isPre);
+ if (!range.sc.tagName && !isInPre) {
+ var changed = this.context.invoke('HelperPlugin.removeExtremeBreakableSpace', range.sc);
+ range.so = range.eo = range.so > changed.start ? range.so - changed.start : 0;
+ range.so = range.eo = range.so > dom.nodeLength(range.sc) ? dom.nodeLength(range.sc) : range.so;
+ range.select();
+ this.context.invoke('editor.saveRange');
+ }
+ return range;
+ },
+ /**
+ * Patch for Google Chrome's contenteditable SPAN bug.
+ *
+ * @private
+ * @param {jQueryEvent} e
+ */
+ _removeGarbageSpans: function (e) {
+ if (e.target.className === "" && e.target.tagName == "SPAN" &&
+ e.target.style.fontStyle === "inherit" &&
+ e.target.style.fontVariantLigatures === "inherit" &&
+ e.target.style.fontVariantCaps === "inherit") {
+ var $span = $(e.target);
+ $span.after($span.contents()).remove();
+ }
+ },
+ /**
+ * Replace all leading space from a text node with one non-breakable space.
+ *
+ * @param {Node} node
+ * @returns {Node} node
+ */
+ _replaceLeadingSpaceWithSingleNBSP: function (node) {
+ var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace');
+ node.textContent = node.textContent.replace(startSpace, '\u00A0');
+ return node;
+ },
+ /**
+ * Replace a media node with an empty SPAN and return that SPAN.
+ *
+ * @param {Node} media
+ * @returns {Node} span
+ */
+ _replaceMediaWithEmptySpan: function (media) {
+ var span = this.document.createElement('span');
+ media = dom.ancestor(media, function (n) {
+ return !n.parentNode || !dom.isMedia(n.parentNode);
+ });
+ $(media).replaceWith(span);
+ return span;
+ },
+ /**
+ * Move the (collapsed) range to get out of BR elements.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Object} range
+ */
+ _rerangeOutOfBR: function (range, direction) {
+ range = this._rerangeToFirstNonBRElementLeaf(range);
+ range = this._rerangeToNextNonBR(range, direction === 'next');
+ return range;
+ },
+ /**
+ * Move the (collapsed) range to the first leaf that is not a BR element.
+ *
+ * @private
+ * @param {Object} range
+ * @returns {Object} range
+ */
+ _rerangeToFirstNonBRElementLeaf: function (range) {
+ var leaf = this.context.invoke('HelperPlugin.firstNonBRElementLeaf', range.sc);
+ if (leaf !== range.sc) {
+ range = this.context.invoke('editor.setRange', leaf, 0);
+ }
+ return range;
+ },
+ /**
+ * Move the (collapsed) range to the next (or previous) node that is not a BR element.
+ *
+ * @private
+ * @param {Object} range
+ * @param {Boolean} previous true to move to the previous node
+ * @returns {Object} range
+ */
+ _rerangeToNextNonBR: function (range, previous) {
+ var point = range.getStartPoint();
+ var method = previous ? 'prevPointUntil' : 'nextPointUntil';
+ point = dom[method](point, function (pt) {
+ return pt.node.tagName !== 'BR';
+ });
+ range = this.context.invoke('editor.setRange', point.node, point.offset);
+ return range;
+ },
+ /**
+ * Move the (collapsed) range to the child of the node at the current offset if possible.
+ *
+ * @private
+ * @param {Object} range
+ * @param {String('prev'|'next')} direction
+ * @returns {Object} range
+ */
+ _rerangeToOffsetChild: function (range, direction) {
+ if (range.sc.childNodes[range.so]) {
+ var node;
+ var offset;
+ if (direction === 'prev' && range.so > 0) {
+ node = range.sc.childNodes[range.so - 1];
+ offset = dom.nodeLength(node);
+ range = this.context.invoke('editor.setRange', node, offset);
+ } else {
+ node = range.sc.childNodes[range.so];
+ offset = 0;
+ range = this.context.invoke('editor.setRange', node, offset);
+ }
+ }
+ return range;
+ },
+ /**
+ * Before a deletion, if necessary, slice the text content at range, then rerange.
+ *
+ * @param {Object} range
+ * @returns {Object} range
+ */
+ _sliceAndRerangeBeforeDeletion: function (range) {
+ if (this._isAfterLeadingInvisibleChar(range) && !this._isAfterTwoBRs(range.sc)) {
+ range.sc.textContent = range.sc.textContent.slice(1);
+ range.so = 0;
+ }
+ if (this._isBeforeTrailingInvisibleChar(range) && !this._isAfterBR(range.sc)) {
+ range.sc.textContent = range.sc.textContent.slice(0, range.so);
+ }
+ return range;
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Handle
+ //--------------------------------------------------------------------------
+
+ /**
+ * Customize handling of certain keydown events.
+ *
+ * @private
+ * @param {SummernoteEvent} se
+ * @param {jQueryEvent} e
+ * @returns {Boolean} true if case handled
+ */
+ _onKeydown: function (se, e) {
+ var self = this;
+ var handled = false;
+
+ if (e.key &&
+ (e.key.length === 1 || e.key === "Dead" || e.key === "Unidentified") &&
+ !e.ctrlKey && !e.altKey && !e.metaKey) {
+
+ if (e.key === "Dead" || e.key === "Unidentified") {
+ this._accented = true;
+ }
+
+ // Record undo only if either:
+ clearTimeout(this.lastCharIsVisibleTime);
+ // e.key is punctuation or space
+ var stopChars = [' ', ',', ';', ':', '?', '.', '!'];
+ if (stopChars.indexOf(e.key) !== -1) {
+ this.lastCharVisible = false;
+ }
+ // or not on top of history stack (record undo after undo)
+ var history = this.context.invoke('HistoryPlugin.getHistoryStep');
+ if (history && history.stack.length && history.stackOffset < history.stack.length - 1) {
+ this.lastCharVisible = false;
+ }
+ // or no new char for 500ms
+ this.lastCharIsVisibleTime = setTimeout(function () {
+ self.lastCharIsVisible = false;
+ }, 500);
+ if (!this.lastCharIsVisible) {
+ this.lastCharIsVisible = true;
+ this.context.invoke('HistoryPlugin.recordUndo');
+ }
+
+ if (e.key !== "Dead") {
+ this._onVisibleChar(e, this._accented);
+ }
+ } else {
+ this.lastCharIsVisible = false;
+ this.context.invoke('editor.clearTarget');
+ this.context.invoke('MediaPlugin.hidePopovers');
+ this.context.invoke('editor.beforeCommand');
+ switch (e.keyCode) {
+ case 8: // BACKSPACE
+ handled = this._onBackspace(e);
+ break;
+ case 9: // TAB
+ handled = this._onTab(e);
+ break;
+ case 13: // ENTER
+ handled = this._onEnter(e);
+ break;
+ case 46: // DELETE
+ handled = this._onDelete(e);
+ break;
+ }
+ if (handled) {
+ this._preventTextInEditableDiv();
+ this.context.invoke('editor.saveRange');
+ e.preventDefault();
+ this.context.invoke('editor.afterCommand');
+ }
+ }
+ if (e.key !== "Dead") {
+ this._accented = false;
+ }
+ },
+ /**
+ * Handle BACKSPACE keydown event.
+ *
+ * @private
+ * @param {jQueryEvent} e
+ * @returns {Boolean} true if case is handled and event default must be prevented
+ */
+ _onBackspace: function (e) {
+ var range = this.context.invoke('editor.createRange');
+ var needOutdent = false;
+
+ // Special cases
+ if (range.isCollapsed()) {
+
+ // Do nothing if on left edge of a table cell
+ var point = range.getStartPoint();
+ if (point.node.childNodes[point.offset]) {
+ point.node = point.node.childNodes[point.offset];
+ point.offset = dom.nodeLength(point.node);
+ }
+ if (this.context.invoke('HelperPlugin.isLeftEdgeOfTag', point, 'TD')) {
+ return true;
+ }
+
+ // Outdent if on left edge of an indented block
+ point = range.getStartPoint();
+ var isIndented = !!dom.ancestor(point.node, function (n) {
+ var style = dom.isCell(n) ? 'paddingLeft' : 'marginLeft';
+ return n.tagName && !!parseFloat(n.style[style] || 0);
+ });
+ if (this.context.invoke('HelperPlugin.isLeftEdgeOfBlock', point)) {
+ if (isIndented) {
+ this.context.invoke('BulletPlugin.outdent');
+ return true;
+ }
+ if (dom.ancestor(range.sc, dom.isLi)) {
+ needOutdent = true;
+ }
+ }
+ }
+
+ var flag = this._handleDeletion('prev');
+
+ if (!flag && needOutdent) {
+ range.select();
+ this.context.invoke('BulletPlugin.outdent');
+ }
+
+ return true;
+ },
+ /**
+ * Handle DELETE keydown event.
+ *
+ * @private
+ * @param {jQueryEvent} e
+ * @returns {Boolean} true if case is handled and event default must be prevented
+ */
+ _onDelete: function (e) {
+ var range = this.context.invoke('editor.createRange');
+
+ // Special case
+ if (range.isCollapsed()) {
+ // Do nothing if on left edge of a table cell
+ if (this.context.invoke('HelperPlugin.isRightEdgeOfTag', range.getStartPoint(), 'TD')) {
+ return true;
+ }
+ }
+
+ this._handleDeletion('next');
+ return true;
+ },
+ /**
+ * Handle ENTER keydown event.
+ *
+ * @private
+ * @param {jQueryEvent} e
+ * @returns {Boolean} true if case is handled and event default must be prevented
+ */
+ _onEnter: function (e) {
+ this.context.invoke('HelperPlugin.deleteSelection');
+ if (e.shiftKey) {
+ this._handleShiftEnter();
+ } else if (e.ctrlKey) {
+ this.context.invoke('TextPlugin.insertHR');
+ } else {
+ this._handleEnter();
+ }
+ return true;
+ },
+ /**
+ * Handle TAB keydown event.
+ *
+ * @private
+ * @param {jQueryEvent} e
+ * @returns {Boolean} true if case is handled and event default must be prevented
+ */
+ _onTab: function (e) {
+ // If TAB not handled, prevent default and do nothing
+ if (!this.options.keyMap.pc.TAB) {
+ this.trigger_up('wysiwyg_blur', {
+ key: 'TAB',
+ keyCode: 9,
+ shiftKey: e.shiftKey,
+ });
+ return true;
+ }
+ var range = this.context.invoke('editor.createRange');
+ var point = range.getStartPoint();
+ var startSpace = this.context.invoke('HelperPlugin.getRegex', 'startSpace');
+
+ if (!range.isOnCell()) {
+ // If on left edge point: indent/outdent
+ if (!point.node.tagName) { // Clean up start spaces on textNode
+ point.node.textContent.replace(startSpace, function (startSpaces) {
+ point.offset = startSpaces.length === point.offset ? 0 : point.offset;
+ return '';
+ });
+ }
+ if (this.context.invoke('HelperPlugin.isLeftEdgeOfBlock', point) || dom.isEmpty(point.node)) {
+ if (e.shiftKey) {
+ this.context.invoke('BulletPlugin.outdent');
+ } else {
+ this.context.invoke('BulletPlugin.indent');
+ }
+ this.context.invoke('HelperPlugin.normalize');
+ return true;
+ }
+ // Otherwise insert a tab or do nothing
+ if (!e.shiftKey) {
+ this.context.invoke('TextPlugin.insertTab');
+ this.context.invoke('HelperPlugin.normalize');
+ }
+ return true;
+ }
+ // In table, on tab switch to next cell
+ return false;
+ },
+ /**
+ * Handle visible char keydown event.
+ *
+ * @private
+ * @param {jQueryEvent} e
+ * @returns {Boolean} true if case is handled and event default must be prevented
+ */
+ _onVisibleChar: function (e, accented) {
+ var self = this;
+ e.preventDefault();
+ if (accented) {
+ this.editable.normalize();
+ var baseRange = this.context.invoke('editor.createRange');
+
+ var $parent = $(baseRange.sc.parentNode);
+ var parentContenteditable = $parent.attr('contenteditable');
+ $parent.attr('contenteditable', false);
+
+ var accentPlaceholder = this.document.createElement('span');
+ $(baseRange.sc).after(accentPlaceholder);
+ $(accentPlaceholder).attr('contenteditable', true);
+
+ var range = this.context.invoke('editor.setRange', accentPlaceholder, 0);
+ range.select();
+
+ setTimeout(function () {
+ var accentedChar = accentPlaceholder.innerHTML;
+ $(accentPlaceholder).remove();
+ if (parentContenteditable) {
+ $parent.attr('contenteditable', parentContenteditable);
+ } else {
+ $parent.removeAttr('contenteditable');
+ }
+ baseRange.select();
+ self.context.invoke('HelperPlugin.insertTextInline', accentedChar);
+ });
+ } else {
+ this.context.invoke('HelperPlugin.insertTextInline', e.key);
+ }
+ return true;
+ },
+});
+
+registry.add('KeyboardPlugin', KeyboardPlugin);
+
+return KeyboardPlugin;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/link.js b/addons/web_editor/static/src/js/wysiwyg/plugin/link.js
new file mode 100644
index 00000000000..e04f3e9d340
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/link.js
@@ -0,0 +1,479 @@
+odoo.define('web_editor.wysiwyg.plugin.link', function (require) {
+'use strict';
+
+var core = require('web.core');
+var LinkDialog = require('wysiwyg.widgets.LinkDialog');
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var _t = core._t;
+var dom = $.summernote.dom;
+dom.isAnchor = function (node) {
+ return (node.tagName === 'A' || node.tagName === 'BUTTON' || $(node).hasClass('btn')) &&
+ !$(node).hasClass('fa') && !$(node).hasClass('o_image');
+};
+
+//--------------------------------------------------------------------------
+// link
+//--------------------------------------------------------------------------
+
+var LinkPlugin = Plugins.linkDialog.extend({
+ events: {
+ 'dblclick .note-editable': '_onDblclick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Public summernote module API
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param {Object} linkInfo
+ * @returns {Promise}
+ */
+ showLinkDialog: function (linkInfo) {
+ var self = this;
+ this.context.invoke('editor.hidePopover');
+ var media = this.context.invoke('editor.restoreTarget');
+
+ if (linkInfo.range) {
+ var r = linkInfo.range.getPoints();
+
+ // move carret in icon, video...
+ if (!media && (dom.ancestor(r.sc, dom.isImg) || dom.ancestor(r.sc, dom.isIcon))) {
+ media = dom.ancestor(r.sc, dom.isImg) || dom.ancestor(r.sc, dom.isIcon);
+ }
+ // if select a text content in anchor then click on image then click on anchor button
+ if (dom.isImg(media) || dom.isIcon(media)) {
+ r.sc = media.parentNode;
+ r.so = [].indexOf.call(media.parentNode.childNodes, media);
+ r.ec = media;
+ r.eo = dom.nodeLength(media);
+
+ linkInfo.range.sc = r.sc;
+ linkInfo.range.so = r.so;
+ linkInfo.range.ec = r.ec;
+ linkInfo.range.eo = r.eo;
+ }
+
+ linkInfo.isAnchor = linkInfo.range.isOnAnchor();
+ linkInfo.className = linkInfo.isAnchor ? dom.ancestor(r.sc, dom.isAnchor).className : '';
+ linkInfo.url = linkInfo.isAnchor ? ($(dom.ancestor(r.sc, dom.isAnchor)).attr('href') || '').replace(window.location.origin, '') : '';
+
+ var nodes = [];
+ if (linkInfo.isAnchor) {
+ nodes = dom.ancestor(r.sc, dom.isAnchor).childNodes;
+ } else if (!linkInfo.range.isCollapsed()) {
+ if (dom.isImg(media) || dom.isIcon(media)) {
+ nodes.push(media);
+ } else {
+ if (r.sc.tagName) {
+ r.sc = (r.so ? r.sc.childNodes[r.so] : r.sc).firstChild || r.sc;
+ r.so = 0;
+ } else if (r.so !== r.sc.textContent.length) {
+ if (r.sc === r.ec) {
+ r.ec = r.sc = r.sc.splitText(r.so);
+ r.eo -= r.so;
+ } else {
+ r.sc = r.sc.splitText(r.so);
+ }
+ r.so = 0;
+ }
+ if (r.ec.tagName) {
+ r.ec = (r.eo ? r.ec.childNodes[r.eo - 1] : r.ec).lastChild || r.ec;
+ r.eo = r.ec.textContent.length;
+ } else if (r.eo !== r.ec.textContent.length) {
+ r.ec.splitText(r.eo);
+ }
+
+ // browsers can't target a picture or void node
+ if (dom.isVoid(r.sc) || dom.isImg(r.sc)) {
+ r.so = dom.listPrev(r.sc).length - 1;
+ r.sc = r.sc.parentNode;
+ }
+ if (dom.isBR(r.ec)) {
+ r.eo = dom.listPrev(r.ec).length - 1;
+ r.ec = r.ec.parentNode;
+ } else if (dom.isVoid(r.ec) || dom.isImg(r.sc)) {
+ r.eo = dom.listPrev(r.ec).length;
+ r.ec = r.ec.parentNode;
+ }
+ linkInfo.range.sc = r.sc;
+ linkInfo.range.so = r.so;
+ linkInfo.range.ec = r.ec;
+ linkInfo.range.eo = r.eo;
+ linkInfo.range.select();
+ this.context.invoke('editor.saveRange');
+ linkInfo.range = this.context.invoke('editor.createRange');
+
+ // search nodes to insert in the anchor
+
+ var startPoint = this.context.invoke('HelperPlugin.makePoint', r.sc, r.so);
+ var endPoint = this.context.invoke('HelperPlugin.makePoint', r.ec, r.eo);
+ dom.walkPoint(startPoint, endPoint, function (point) {
+ var node = point.node.childNodes && point.node.childNodes[point.offset] || point.node;
+ nodes.push(node);
+ });
+
+ nodes = _.filter(_.uniq(nodes), function (node) {
+ return nodes.indexOf(node.parentNode) === -1;
+ });
+ }
+ }
+
+ if (nodes.length > 0) {
+ var text = "";
+ linkInfo.images = [];
+ for (var i = 0; i < nodes.length; i++) {
+ if (dom.ancestor(nodes[i], dom.isImg)) {
+ text += dom.ancestor(nodes[i], dom.isImg).outerHTML;
+ } else if (dom.ancestor(nodes[i], dom.isIcon)) {
+ text += dom.ancestor(nodes[i], dom.isIcon).outerHTML;
+ } else if (!linkInfo.isAnchor && nodes[i].nodeType === 1) {
+ // just use text nodes from listBetween
+ } else if (!linkInfo.isAnchor && i === 0) {
+ text += nodes[i].textContent;
+ } else if (!linkInfo.isAnchor && i === nodes.length - 1) {
+ text += nodes[i].textContent;
+ } else {
+ text += nodes[i].textContent;
+ }
+ }
+ linkInfo.text = text.replace(this.context.invoke('HelperPlugin.getRegex', 'space', 'g'), ' ');
+ }
+
+ linkInfo.needLabel = !linkInfo.text.length;
+ }
+
+ var def = $.Deferred();
+ var linkDialog = new LinkDialog(this.options.parent, {
+ onClose: function () {
+ setTimeout(function () {
+ self.context.invoke('editor.focus');
+ });
+ }
+ },
+ _.omit(linkInfo, 'range')
+ );
+
+ linkDialog.on('save', this, this._wrapCommand(function (newLinkInfo) {
+ var isCollapsed = linkInfo.range.isCollapsed();
+ linkInfo.range.select();
+ var $anchor;
+ if (linkInfo.isAnchor) {
+ $anchor = $(dom.ancestor(r.sc, dom.isAnchor));
+ $anchor.css(newLinkInfo.style || {});
+ if (newLinkInfo.isNewWindow) {
+ $anchor.attr('target', '_blank');
+ } else {
+ $anchor.removeAttr('target');
+ }
+ } else {
+ self.context.invoke('editor.saveRange');
+ def.resolve(_.clone(newLinkInfo));
+ var range = self.context.invoke('editor.createRange');
+ var anchor = dom.ancestor(range.sc.childNodes[range.so] || range.sc, dom.isAnchor);
+ $anchor = $(anchor);
+ if (isCollapsed) {
+ // move the range just after the link
+ var point = dom.nextPoint({
+ node: anchor,
+ offset: dom.nodeLength(anchor),
+ });
+ range = self.context.invoke('editor.setRange', point.node, point.offset);
+ range.select();
+ } else {
+ $anchor.selectContent();
+ }
+ }
+ if ((dom.isImg(media) || dom.isIcon(media)) && !$anchor.find(media).length) {
+ $(media).remove();
+ }
+ $anchor.attr('class', newLinkInfo.className);
+ $anchor.attr('href', newLinkInfo.url);
+ self.context.invoke('editor.saveRange');
+ self.context.invoke('editor.saveTarget', $anchor[0]);
+ self.context.triggerEvent('focusnode', $anchor[0]);
+ }));
+ linkDialog.on('closed', this, function () {
+ def.reject();
+ this.context.invoke('editor.restoreRange');
+ this.context.invoke('LinkPopover.update');
+ });
+
+ linkDialog.open();
+ return def.promise();
+ },
+ /**
+ * Remove the current link, keep its contents.
+ *
+ * @override
+ */
+ unlink: function () {
+ var rng = this.context.invoke('editor.createRange');
+ var anchor = rng.sc;
+ while (anchor && anchor.tagName !== 'A') {
+ anchor = anchor.parentElement;
+ }
+ if (!anchor) {
+ this.context.invoke('editor.hidePopover');
+ return;
+ }
+ var startAndEndInvisible = this.context.invoke('HelperPlugin.getRegex', 'startAndEndInvisible', 'g');
+ anchor.innerHTML = anchor.innerHTML.replace(startAndEndInvisible, '');
+ var $contents = $(anchor).contents();
+ $(anchor).before($contents).remove();
+
+ this.context.invoke('editor.hidePopover');
+
+ var start = $contents[0];
+ var end = $contents.last()[0];
+ rng = this.context.invoke('editor.setRange', start, 0, end, dom.nodeLength(end));
+ rng.select();
+ this.editable.normalize();
+ this.context.invoke('editor.saveRange');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _addButtons: function () {
+ var self = this;
+ this._super();
+
+ this.context.memo('help.LinkPlugin.show', this.options.langInfo.help['linkDialog.show']);
+
+ this.context.memo('button.linkPlugin', function () {
+ return self.context.invoke('buttons.button', {
+ contents: self.ui.icon(self.options.icons.link),
+ tooltip: self.lang.link.link + self.context.invoke('buttons.representShortcut', 'LinkPlugin.show'),
+ click: self.context.createInvokeHandler('LinkPlugin.show')
+ }).render();
+ });
+ },
+ /**
+ * @param {jQueryEvent} e
+ */
+ _onDblclick: function (e) {
+ if (dom.isAnchor(e.target)) {
+ this.show();
+ }
+ },
+});
+
+var LinkPopover = Plugins.linkPopover.extend({
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prevent links without text: replace empty text with the word "Label".
+ *
+ * @param {DOM} anchor
+ * @param {bool} select
+ */
+ fillEmptyLink: function (anchor, select) {
+ if (dom.isAnchor(anchor)) {
+ var text = _t('Label');
+ if (this.context.invoke('HelperPlugin.getRegex', 'onlyEmptySpace').test(anchor.innerHTML)) {
+ $(anchor).contents().remove();
+ $(anchor).append(this.document.createTextNode(text));
+ }
+ var rng = this.context.invoke('editor.createRange');
+ if (select && anchor.innerHTML === text) {
+ this._cleanLastLink();
+ rng.sc = rng.ec = anchor.firstChild;
+ rng.so = 0;
+ rng.eo = dom.nodeLength(rng.sc);
+ rng.select();
+ this.context.invoke('editor.saveRange');
+ }
+ if (select) {
+ this.lastAnchor = anchor;
+ }
+ }
+ },
+ /**
+ * @override
+ */
+ hide: function () {
+ this._cleanLastLink();
+ this._super();
+ },
+ /**
+ * @override
+ */
+ update: function () {
+ var rng = this.context.invoke('editor.createRange');
+ var anchor = dom.ancestor(rng.sc, dom.isAnchor);
+ if (anchor && anchor === dom.ancestor(rng.ec, dom.isAnchor)) {
+ anchor = dom.ancestor(rng.sc, dom.isAnchor);
+ if (!$(anchor).is(':o_editable')) {
+ this.hide();
+ return;
+ }
+ } else {
+ anchor = false;
+ }
+
+ if ($(anchor).data('toggle') === 'tab') {
+ anchor = false;
+ }
+
+ if (!this.options.displayPopover(anchor)) {
+ anchor = false;
+ }
+
+ if (anchor !== this.lastAnchor) {
+ this._cleanLastLink();
+ }
+
+ if (!anchor) {
+ this.hide();
+ return;
+ }
+
+ if (dom.isAnchor(anchor)) {
+ this.lastAnchor = anchor;
+ }
+
+
+ var $target = $(anchor);
+ if (!$target.data('show_tooltip')) {
+ $target.data('show_tooltip', true);
+ setTimeout(function () {
+ $target.tooltip({
+ title: _t('Double-click to edit'),
+ trigger: 'manuel',
+ container: this.document.body,
+ placement: 'top'
+ }).tooltip('show');
+ setTimeout(function () {
+ $target.tooltip('dispose');
+ }, 2000);
+ }, 400);
+ }
+
+ var innerHTML = anchor.innerHTML.replace('&nbsp;', '\u00A0').replace('&#8203;', '\u200B');
+
+ // prevent links without text
+ this.fillEmptyLink(anchor, true);
+
+ // add invisible char to prevent the carret to leave the link at the begin or end
+ if (innerHTML[0] !== '\u200B') {
+ var before = this.document.createTextNode('\u200B');
+ $(anchor).prepend(before);
+ }
+ if (innerHTML[innerHTML.length - 1] !== '\u200B') {
+ var after = this.document.createTextNode('\u200B');
+ $(anchor).append(after);
+ }
+
+ anchor.normalize();
+
+ var href = $(anchor).attr('href');
+ this.$popover.find('a').attr('href', href).html(href);
+
+ var pos = $(anchor).offset();
+ var posContainer = $(this.options.container).offset();
+ pos.left = pos.left - posContainer.left + 10;
+ pos.top = pos.top - posContainer.top + $(anchor).outerHeight();
+
+ this.$popover.css({
+ display: 'block',
+ left: pos.left,
+ top: pos.top,
+ });
+
+ this.context.layoutInfo.editor.after(this.$popover);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clean the last link and prevent links without text.
+ *
+ * @private
+ */
+ _cleanLastLink: function () {
+ if (this.lastAnchor) {
+ this.fillEmptyLink(this.lastAnchor);
+
+ var range = this.context.invoke('editor.createRange');
+ var rangeChange;
+ var innerHTML = this.lastAnchor.innerHTML.replace('&nbsp;', '\u00A0').replace('&#8203;', '\u200B');
+ // prevent links without text
+ if (
+ this.$editable.has(this.lastAnchor).length &&
+ this.context.invoke('HelperPlugin.getRegexBlank', {
+ space: true,
+ invisible: true,
+ nbsp: true,
+ }).test(innerHTML)
+ ) {
+ $(this.lastAnchor).contents().remove();
+ $(this.lastAnchor).append(this.document.createTextNode(_t('Label')));
+ range.sc = range.ec = this.lastAnchor.firstChild;
+ range.so = 0;
+ range.eo = dom.nodeLength(range.sc);
+ rangeChange = true;
+ }
+ var firstChild = this.context.invoke('HelperPlugin.firstLeaf', this.lastAnchor);
+ if (
+ !firstChild.tagName &&
+ this.context.invoke('HelperPlugin.getRegex', 'startInvisible').test(firstChild.textContent)
+ ) {
+ firstChild.textContent = firstChild.textContent.replace(this.context.invoke('HelperPlugin.getRegex', 'startInvisible'), '');
+ if (range.sc === firstChild && range.so) {
+ range.so -= 1;
+ rangeChange = true;
+ }
+ if (range.ec === firstChild && range.eo) {
+ range.eo -= 1;
+ rangeChange = true;
+ }
+ }
+ var lastChild = this.context.invoke('HelperPlugin.lastLeaf', this.lastAnchor);
+ if (
+ lastChild.textContent.length > 1 &&
+ !lastChild.tagName &&
+ this.context.invoke('HelperPlugin.getRegex', 'endInvisible').test(lastChild.textContent)
+ ) {
+ lastChild.textContent = lastChild.textContent.replace(this.context.invoke('HelperPlugin.getRegex', 'endInvisible'), '');
+ if (range.sc === lastChild && range.so > dom.nodeLength(lastChild)) {
+ range.so = dom.nodeLength(lastChild);
+ rangeChange = true;
+ }
+ if (range.ec === lastChild && range.eo > dom.nodeLength(lastChild)) {
+ range.eo = dom.nodeLength(lastChild);
+ rangeChange = true;
+ }
+ }
+ if (rangeChange) {
+ range.select();
+ this.context.invoke('editor.saveRange');
+ }
+ this.lastAnchor = null;
+ }
+ },
+});
+
+registry.add('LinkPlugin', LinkPlugin)
+ .add('LinkPopover', LinkPopover)
+ .add('linkDialog', null)
+ .add('linkPopover', null)
+ .add('autoLink', null);
+
+return {
+ LinkPlugin: LinkPlugin,
+ LinkPopover: LinkPopover,
+};
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/media.js b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js
new file mode 100644
index 00000000000..f136b25830f
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/media.js
@@ -0,0 +1,1232 @@
+odoo.define('web_editor.wysiwyg.plugin.media', function (require) {
+'use strict';
+
+var core = require('web.core');
+var weWidgets = require('wysiwyg.widgets');
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+var Plugins = require('web_editor.wysiwyg.plugins');
+var wysiwygTranslation = require('web_editor.wysiwyg.translation');
+var wysiwygOptions = require('web_editor.wysiwyg.options');
+
+var _t = core._t;
+
+var dom = $.summernote.dom;
+var ui = $.summernote.ui;
+
+//--------------------------------------------------------------------------
+// Media (for image, video, icon, document)
+//--------------------------------------------------------------------------
+
+/**
+ * Return true if the node is a media (image, icon, document or video).
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+dom.isMedia = function (node) {
+ return dom.isImg(node) ||
+ dom.isIcon(node) ||
+ dom.isDocument(node) ||
+ dom.isVideo(node);
+};
+
+var MediaPlugin = AbstractPlugin.extend({
+ events: {
+ 'summernote.mousedown': '_onMouseDown',
+ 'summernote.keydown': '_onKeydown',
+ 'summernote.keyup': '_onKeyup',
+ 'summernote.scroll': '_onScroll',
+ 'summernote.disable': '_onDisable',
+ 'summernote.change': '_onChange',
+ 'summernote.codeview.toggled': '_onToggled',
+ 'dblclick .note-editable': '_onDblclick',
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Open the image dialog and listen to its saved/closed events.
+ */
+ showImageDialog: function () {
+ this.context.invoke('editor.saveRange');
+ var media = this.context.invoke('editor.restoreTarget');
+
+ var mediaDialog = new weWidgets.MediaDialog(this.options.parent, {},
+ $(media).clone()[0]
+ );
+
+ mediaDialog.on('saved', this, function (data) {
+ this.insertMedia(media, data);
+ });
+ mediaDialog.on('closed', this, function () {
+ this.context.invoke('editor.restoreRange');
+ });
+ mediaDialog.open();
+ },
+ /**
+ * Remove the current target media and hide its popover.
+ */
+ removeMedia: function () {
+ this.context.invoke('editor.beforeCommand');
+ var target = this.context.invoke('editor.restoreTarget');
+ var point = this.context.invoke('HelperPlugin.removeBlockNode', target);
+ var rng = this.context.invoke('editor.setRange', point.node, point.offset);
+ rng.normalize().select();
+ this.context.invoke('editor.saveRange');
+ this.context.invoke('editor.clearTarget');
+ this.hidePopovers();
+ this.context.invoke('editor.afterCommand');
+ },
+ /**
+ * Update the target media and its popover.
+ *
+ * @param {Node} target
+ */
+ update: function (target) {
+ if (!target) {
+ return;
+ }
+ if (!this.options.displayPopover(target)) {
+ return;
+ }
+
+ this.lastPos = this.context.invoke('HelperPlugin.makePoint', target, $(target).offset());
+
+ this.context.triggerEvent('focusnode', target);
+
+ if (!dom.isMedia(target)) {
+ return;
+ }
+
+ this.context.invoke('editor.saveTarget', target);
+
+ var $target = $(target);
+ if (!$target.data('show_tooltip')) {
+ $target.data('show_tooltip', true);
+ setTimeout(function () {
+ $target.tooltip({
+ title: _t('Double-click to edit'),
+ trigger: 'manuel',
+ container: this.document.body,
+ }).tooltip('show');
+ setTimeout(function () {
+ $target.tooltip('dispose');
+ }, 2000);
+ }, 400);
+ }
+
+ if (dom.isImg(target)) {
+ this.context.invoke('ImagePlugin.show', target, this.mousePosition);
+ } else if (dom.isIcon(target)) {
+ this.context.invoke('IconPlugin.show', target, this.mousePosition);
+ } else if (dom.isVideo(target)) {
+ this.context.invoke('VideoPlugin.show', target, this.mousePosition);
+ } else if (dom.isDocument(target)) {
+ this.context.invoke('DocumentPlugin.show', target, this.mousePosition);
+ }
+ },
+ /**
+ * Hide all open popovers.
+ * Warning: removes the saved target.
+ */
+ hidePopovers: function () {
+ var media = this.context.invoke('editor.restoreTarget');
+ this.context.invoke('HandlePlugin.hide');
+ this.context.invoke('ImagePlugin.hide');
+ this.context.invoke('IconPlugin.hide');
+ this.context.invoke('VideoPlugin.hide');
+ this.context.invoke('DocumentPlugin.hide');
+ this.context.invoke('editor.saveTarget', media);
+ },
+ /**
+ * Insert or replace a media.
+ *
+ * @param {Node} previous the media to replace, if any
+ * @param {Object} data contains the media to insert
+ */
+ insertMedia: function (previous, data) {
+ var newMedia = data.media;
+ this._wrapCommand(function () {
+ this.$editable.focus();
+ var rng = this.context.invoke('editor.createRange');
+ var point;
+
+ if (newMedia.tagName === "IMG") {
+ $(newMedia).one('load error abort', this.updatePopoverAfterEdit.bind(this, newMedia));
+ }
+
+ if (previous) {
+ this.context.invoke('editor.clearTarget');
+ var start = previous.parentNode;
+ rng = this.context.invoke('editor.setRange', start, _.indexOf(start.childNodes, previous));
+ if (previous.tagName === "IMG" && $(previous).hasClass('img-fluid')) {
+ $(newMedia).addClass('img img-fluid mx-auto');
+ }
+
+ if (dom.isVideo(previous) || dom.isVideo(newMedia)) {
+ var doNotInsertP = previous.tagName === newMedia.tagName;
+ point = this.context.invoke('HelperPlugin.removeBlockNode', previous, doNotInsertP);
+ if (!rng.sc.parentNode || !rng.sc.childNodes[rng.so]) {
+ rng = this.context.invoke('editor.setRange', point.node, point.offset);
+ }
+ previous = null;
+ }
+ rng.select();
+ this.hidePopovers();
+ }
+
+ if (dom.isVideo(newMedia)) {
+ this.context.invoke('HelperPlugin.insertBlockNode', newMedia);
+ } else {
+ rng = this.context.invoke('editor.createRange');
+ point = rng.getStartPoint();
+ if (!rng.isCollapsed()) {
+ point = this.context.invoke('HelperPlugin.deleteBetween', point, rng.getEndPoint());
+ }
+
+ if (point.node.tagName) {
+ if (previous) {
+ $(previous).replaceWith(newMedia);
+ } else if (dom.isVoid(point.node)) {
+ point.node.parentNode.insertBefore(newMedia, point.node);
+ } else {
+ var node = point.node.childNodes[point.offset];
+ if (point.node.tagName === 'BR') {
+ $(point.node).replaceWith(newMedia);
+ } else if (node && node.tagName === 'BR') {
+ $(node).replaceWith(newMedia);
+ } else {
+ point.node.insertBefore(newMedia, node || null);
+ }
+ }
+ if (!newMedia.previousSibling) {
+ $(newMedia).before(this.document.createTextNode('\u200B'), newMedia);
+ }
+ if (!newMedia.nextSibling) {
+ $(newMedia).after(this.document.createTextNode('\u200B'), newMedia);
+ }
+ } else {
+ var next = this.document.createTextNode(point.node.textContent.slice(point.offset));
+ point.node.textContent = point.node.textContent.slice(0, point.offset);
+
+ $(point.node).after(next).after(newMedia);
+ point.node.parentNode.normalize();
+ if (!newMedia.previousSibling) {
+ $(newMedia).before(this.document.createTextNode('\u200B'), newMedia);
+ }
+ if (!newMedia.nextSibling) {
+ $(newMedia).after(this.document.createTextNode('\u200B'), newMedia);
+ }
+ rng = this.context.invoke('editor.setRange', newMedia.nextSibling || newMedia, 0);
+ rng.normalize().select();
+ }
+ }
+ this.context.invoke('editor.saveRange');
+ this.context.invoke('editor.saveTarget', newMedia);
+ this.context.triggerEvent('focusnode', newMedia);
+ this.context.invoke('UnbreakablePlugin.secureArea', newMedia);
+
+ this.updatePopoverAfterEdit(newMedia);
+ })();
+ },
+ /**
+ * Update the media's popover and its position after editing the media.
+ *
+ * @param {Node} media
+ */
+ updatePopoverAfterEdit: function (media) {
+ this.mousePosition = {
+ pageX: $(media).offset().left + $(media).width() / 2,
+ pageY: $(media).offset().top + $(media).height() / 2,
+ };
+ $(media).trigger('mousedown').trigger('mouseup');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add the media popovers' buttons:
+ * - Replacement
+ * - Removal
+ * - Alignment
+ * - Padding
+ *
+ * @override
+ */
+ _addButtons: function () {
+ var self = this;
+ this._super();
+
+ this.context.memo('button.mediaPlugin', function () {
+ return self.context.invoke('buttons.button', {
+ contents: self.ui.icon(self.options.icons.picture),
+ tooltip: self.lang.image.image,
+ click: self.context.createInvokeHandler('MediaPlugin.showImageDialog')
+ }).render();
+ });
+
+ this.context.memo('button.removePluginMedia', function () {
+ return self.context.invoke('buttons.button', {
+ contents: self.ui.icon(self.options.icons.trash),
+ tooltip: self.lang.image.remove,
+ click: self._wrapCommand(function () {
+ this.context.invoke('MediaPlugin.removeMedia');
+ })
+ }).render();
+ });
+
+ _.each(['left', 'center', 'right', 'none'], function (align) {
+ var alignName = _.str.camelize('align_' + align);
+ self._createButton(alignName, self.options.icons[alignName], self.lang.image[alignName], function () {
+ var $target = $(self.context.invoke('editor.restoreTarget'));
+ $target.css('float', '').removeClass('mx-auto pull-right pull-left');
+ if (align === 'center') {
+ $target.addClass('mx-auto');
+ } else if (align !== 'none') {
+ $target.addClass('pull-' + align);
+ }
+ });
+ });
+
+ var padding = [null, 'padding-small', 'padding-medium', 'padding-large', 'padding-xl'];
+ var zipped = _.zip(padding, this.lang.image.paddingList);
+ var values = _.map(zipped, function (z) {
+ return {
+ value: z[0],
+ string: z[1],
+ };
+ });
+ this._createDropdownButton('padding', this.options.icons.padding, this.lang.image.padding, values);
+ },
+ /**
+ * Select the target media based on the
+ * currently saved target or on the current range.
+ *
+ * @private
+ * @param {Node} [target] optional
+ * @returns {Node} target
+ */
+ _selectTarget: function (target) {
+ if (!target) {
+ target = this.context.invoke('editor.restoreTarget');
+ }
+
+ if (this.context.isDisabled()) {
+ this.hidePopovers();
+ this.context.invoke('editor.clearTarget');
+ return target;
+ }
+ var range = this.context.invoke('editor.createRange');
+ if (!target && range.isCollapsed()) {
+ target = range.sc.childNodes[range.so] || range.sc;
+ }
+ if (!target || !dom.isMedia(target)) {
+ this.hidePopovers();
+ this.context.invoke('editor.clearTarget');
+ return target;
+ }
+
+ while (target.parentNode && dom.isMedia(target.parentNode)) {
+ target = target.parentNode;
+ }
+
+ if (!this.options.isEditableNode(target)) {
+ if (!target.parentNode) {
+ target = this.editable;
+ }
+ this.hidePopovers();
+ this.context.invoke('editor.clearTarget');
+ return target;
+ }
+
+ this.context.invoke('editor.saveTarget', target);
+ this.context.triggerEvent('focusnode', target);
+
+ return target;
+ },
+ /**
+ * Select the target media on the right (or left)
+ * of the currently selected target media.
+ *
+ * @private
+ * @param {Node} target
+ * @param {Boolean} left
+ */
+ _moveTargetSelection: function (target, left) {
+ if (!target || !dom.isMedia(target)) {
+ return;
+ }
+ var range = this.context.invoke('editor.createRange');
+ var $contentEditable;
+
+ if (
+ range.sc.tagName && $.contains(target, range.sc) &&
+ $(range.sc).hasClass('o_fake_editable') &&
+ left === !range.sc.previousElementSibling
+ ) {
+ $contentEditable = $(range.sc).closest('[contentEditable]');
+ if ($(target).closest('[contentEditable]')[0] !== $contentEditable[0]) {
+ $contentEditable.focus();
+ }
+ this.context.invoke('editor.saveRange');
+ return;
+ }
+
+ var next = this.context.invoke('HelperPlugin.makePoint', target, 0);
+ if (left) {
+ if (dom.isVideo(target)) {
+ next = this.context.invoke('HelperPlugin.makePoint', target.firstElementChild, 0);
+ } else {
+ next = dom.prevPointUntil(next, function (point) {
+ return point.node !== target && !$.contains(target, point.node);
+ }) || next;
+ }
+ } else {
+ if (dom.isVideo(target)) {
+ next = this.context.invoke('HelperPlugin.makePoint', target.lastElementChild, 0);
+ } else {
+ next = dom.nextPointUntil(next, function (point) {
+ return point.node !== target && !$.contains(target, point.node);
+ }) || next;
+ }
+ }
+
+ $contentEditable = $(next.node).closest('[contentEditable]');
+ if ($(target).closest('[contentEditable]')[0] !== $contentEditable[0]) {
+ // move the focus only if the new contentEditable is not the same (avoid scroll up)
+ // (like in the case of a video, which uses two contentEditable in the media, so as to write text)
+ $contentEditable.focus();
+ }
+
+ range = this.context.invoke('editor.setRange', next.node, next.offset);
+ range.select();
+
+ this.context.invoke('editor.saveRange');
+ },
+
+ //--------------------------------------------------------------------------
+ // handle
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onDisable: function () {
+ this.hidePopovers();
+ this.context.invoke('editor.clearTarget');
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} e
+ */
+ _onDblclick: function (e) {
+ if (dom.isMedia(e.target)) {
+ var target = this._selectTarget(e.target);
+ this._moveTargetSelection(target);
+ this.showImageDialog();
+ }
+ },
+ /**
+ * @private
+ **/
+ _onKeydown: function () {
+ this.context.invoke('editor.clearTarget');
+ },
+ /**
+ * @private
+ * @param {SummernoteEvent} se
+ * @param {jQueryEvent} e
+ */
+ _onKeyup: function (se, e) {
+ var target = this._selectTarget();
+ var range = this.context.invoke('editor.createRange');
+ if (e.keyCode === 37) {
+ var point = dom.prevPoint(range.getStartPoint());
+ if (dom.isMedia(point.node)) {
+ target = point.node;
+ }
+ }
+ this._moveTargetSelection(target, e.keyCode === 37);
+ return this.update(target);
+ },
+ /**
+ * @private
+ */
+ _onScroll: function () {
+ var target = this._selectTarget();
+ if (this.lastPos && this.lastPos.target === target && $(target).offset()) {
+ var newTop = $(target).offset().top;
+ var movement = this.lastPos.offset.top - newTop;
+ if (movement && this.mousePosition) {
+ this.mousePosition.pageY -= movement;
+ }
+ }
+ return this.update(target);
+ },
+ /**
+ * @private
+ */
+ _onChange: function () {
+ var target = this._selectTarget();
+ this._moveTargetSelection(target);
+ if (!this.$editable.has(target).length) {
+ return;
+ }
+ return this.update(target);
+ },
+ /**
+ * @private
+ * @param {SummernoteEvent} se
+ * @param {jQueryEvent} e
+ */
+ _onMouseDown: function (se, e) {
+ var target = this._selectTarget(e.target);
+ if (target && dom.isMedia(target)) {
+ var pos = $(target).offset();
+
+ if (e.pageX) {
+ this.mousePosition = {
+ pageX: e.pageX,
+ pageY: e.pageY,
+ };
+ } else {
+ // for testing triggers
+ this.mousePosition = {
+ pageX: pos.left,
+ pageY: pos.top,
+ };
+ }
+
+ var width = $(target).width();
+ // we put the cursor to the left if we click in the first tier of the media
+ var left = this.mousePosition.pageX < (pos.left + width / 3);
+ this._moveTargetSelection(target, left);
+
+ this.update(target);
+ e.preventDefault();
+ } else {
+ this.mousePosition = {};
+ }
+ },
+ /**
+ * @private
+ */
+ _onToggled: function () {
+ this.update();
+ },
+});
+
+_.extend(wysiwygOptions.icons, {
+ alignCenter: 'note-icon-align-center',
+ alignNone: wysiwygOptions.icons.alignJustify,
+ picture: 'fa fa-file-image-o',
+});
+_.extend(wysiwygTranslation.image, {
+ alignRight: wysiwygTranslation.image.floatRight,
+ alignCenter: _t('Align center'),
+ alignLeft: wysiwygTranslation.image.floatLeft,
+ alignNone: wysiwygTranslation.image.floatNone,
+});
+
+//--------------------------------------------------------------------------
+// Abstract
+//--------------------------------------------------------------------------
+
+var AbstractMediaPlugin = AbstractPlugin.extend({
+ targetType: null,
+ initialize: function () {
+ this._super.apply(this, arguments);
+ this.$popover = this.ui.popover({
+ className: 'note-' + this.targetType + '-popover',
+ })
+ .render().appendTo(this.options.container);
+ var $content = this.$popover.find('.popover-content, .note-popover-content');
+ this.context.invoke('buttons.build', $content, this.options.popover[this.targetType]);
+ this.options.POPOVER_MARGIN = 15;
+ },
+ destroy: function () {
+ this.$popover.remove();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Hide this popover.
+ */
+ hide: function () {
+ this.$popover.hide();
+ },
+ /**
+ * Show this popover.
+ *
+ * @param {Node} target
+ * @param {Object} mousePosition {pageX: Number, pageY: Number}
+ */
+ show: function (target, mousePosition) {
+ this._popoverPosition(target, mousePosition);
+ ui.toggleBtnActive(this.$popover.find('a, button'), false);
+ var $target = $(target);
+
+ var float = $target.css('float');
+ if (float === 'none' && $target.hasClass('mx-auto')) {
+ float = 'center';
+ }
+ var floatIcon = this.options.icons[_.str.camelize('align_' + (float !== 'none' ? float : 'justify'))];
+ ui.toggleBtnActive(this.$popover.find('.note-float button:has(.' + floatIcon + ')'), true);
+
+ var padding = (($target.attr('class') || '').match(/(^| )(padding-[^\s]+)( |$)/) || ['fa-1x'])[2];
+ ui.toggleBtnActive(this.$popover.find('.note-padding a:has(li[data-value="' + padding + '"])'), true);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Takes the left and top position of the popover and an optional margin
+ * and returns updated positions to force the popover to fit the editor container.
+ *
+ * @private
+ * @param {Number} left
+ * @param {Number} top
+ * @param {Number} [margin] optional
+ * @returns {Object} {left: Number, top: Number}
+ */
+ _popoverFitEditor: function (left, top, margin) {
+ margin = margin || this.options.POPOVER_MARGIN;
+
+ var $container = $(this.options.container);
+ var containerWidth = $container.width();
+ var containerHeight = $container.height();
+
+ var popoverWidth = this.$popover.width();
+ var popoverHeight = this.$popover.height();
+
+ var isBeyondXBounds = left + popoverWidth >= containerWidth - margin;
+ var isBeyondYBounds = top + popoverHeight >= containerHeight - margin;
+ return {
+ left: isBeyondXBounds ? containerWidth - popoverWidth - margin : left,
+ top: isBeyondYBounds ? top = containerHeight - popoverHeight - margin : (top > 0 ? top : margin),
+ };
+ },
+ /**
+ * Update the position of the popover in CSS.
+ *
+ * @private
+ * @param {Node} target
+ * @param {Object} mousePosition {pageX: Number, pageY: Number}
+ */
+ _popoverPosition: function (target, mousePosition) {
+ var pos = $(this.options.container).offset();
+ pos.left = mousePosition.pageX - pos.left + this.options.POPOVER_MARGIN;
+ pos.top = mousePosition.pageY - pos.top + this.options.POPOVER_MARGIN;
+
+ var popoverPos = this._popoverFitEditor(pos.left, pos.top);
+ this.$popover.css({
+ display: 'block',
+ left: popoverPos.left,
+ top: popoverPos.top,
+ });
+ },
+ /**
+ * Override to return whether the target is a media (specific to its class) or not.
+ *
+ * @private
+ * @param {Node} target
+ */
+ _isMedia: function (target) {},
+});
+
+//--------------------------------------------------------------------------
+// Image
+//--------------------------------------------------------------------------
+
+var ImagePlugin = AbstractMediaPlugin.extend({
+ targetType: 'image',
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Open the crop image dialog and listen to its saved/closed events.
+ */
+ cropImageDialog: function () {
+ this.context.invoke('editor.saveRange');
+
+ var media = this.context.invoke('editor.restoreTarget');
+ var cropImageDialog = new weWidgets.CropImageDialog(this.options.parent, {},
+ $(media).clone()
+ );
+ cropImageDialog.on('saved', this, function (data) {
+ this.context.invoke('MediaPlugin.insertMedia', media, data);
+ });
+ cropImageDialog.on('closed', this, function () {
+ this.context.invoke('editor.restoreRange');
+ });
+
+ cropImageDialog.open();
+ },
+ /**
+ * Open the alt dialog (change image title and alt) and listen to its saved/closed events.
+ */
+ altDialg: function () {
+ this.context.invoke('editor.saveRange');
+
+ var media = this.context.invoke('editor.restoreTarget');
+ var altDialog = new weWidgets.AltDialog(this.options.parent, {},
+ $(media).clone()
+ );
+ altDialog.on('saved', this, this._wrapCommand(function (data) {
+ $(media).attr('alt', $(data.media).attr('alt'))
+ .attr('title', $(data.media).attr('title'))
+ .trigger('content_changed');
+ }));
+ altDialog.on('closed', this, function () {
+ this.context.invoke('editor.restoreRange');
+ });
+
+ altDialog.open();
+ },
+ /**
+ * Show the image target's popover.
+ *
+ * @override
+ * @param {Node} target
+ * @param {Object} mousePosition {pageX: Number, pageY: Number}
+ */
+ show: function (target, mousePosition) {
+ var self = this;
+ this._super.apply(this, arguments);
+ var $target = $(target);
+
+ this.context.invoke('HandlePlugin.update', target);
+
+ _.each(this.options.icons.imageShape, function (icon, className) {
+ var thisIconSel = '.note-imageShape button:has(.' +
+ icon.replace(self.context.invoke('HelperPlugin.getRegex', 'space', 'g'), '.') +
+ ')';
+ ui.toggleBtnActive(self.$popover.find(thisIconSel), $target.hasClass(className));
+ });
+
+ var size = (($target.attr('style') || '').match(/width:\s*([0-9]+)%/i) || [])[1];
+ ui.toggleBtnActive(this.$popover.find('.note-imagesize button:contains(' + (size ? size + '%' : this.lang.image.imageSizeAuto) + ')'), true);
+
+ ui.toggleBtnActive(this.$popover.find('.note-cropImage button'), $target.hasClass('o_cropped_img_to_save'));
+
+ // update alt button in popover
+ if ($target.attr('alt')) {
+ var $altLabel = $(this.altBtnPrefix).text(function (i, v) {
+ var newText = v + '\u00A0' + $target.attr('alt');
+ return $.trim(newText).substring(0, 30).trim(this) + "...";
+ });
+ this.$popover.find('.note-alt button').contents().replaceWith($altLabel);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Save cropped images.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _saveCroppedImages: function () {
+ var self = this;
+ var defs = this.$editables.find('.o_cropped_img_to_save').map(function () {
+ var $croppedImg = $(this);
+ $croppedImg.removeClass('o_cropped_img_to_save');
+ var resModel = $croppedImg.data('crop:resModel');
+ var resID = $croppedImg.data('crop:resID');
+ var cropID = $croppedImg.data('crop:id');
+ var mimetype = $croppedImg.data('crop:mimetype');
+ var originalSrc = $croppedImg.data('crop:originalSrc');
+ var datas = $croppedImg.attr('src').split(',')[1];
+ if (!cropID) {
+ var name = originalSrc + '.crop';
+ return self._rpc({
+ model: 'ir.attachment',
+ method: 'create',
+ args: [{
+ res_model: resModel,
+ res_id: resID,
+ name: name,
+ datas_fname: name,
+ datas: datas,
+ mimetype: mimetype,
+ url: originalSrc, // To save the original image that was cropped
+ }],
+ }).then(function (attachmentID) {
+ return self._rpc({
+ model: 'ir.attachment',
+ method: 'generate_access_token',
+ args: [
+ [attachmentID],
+ ],
+ }).then(function (access_token) {
+ $croppedImg.attr('src', '/web/image/' + attachmentID + '?access_token=' + access_token[0]);
+ });
+ });
+ } else {
+ return self._rpc({
+ model: 'ir.attachment',
+ method: 'write',
+ args: [
+ [cropID], {
+ datas: datas,
+ },
+ ],
+ });
+ }
+ }).get();
+ return $.when.apply($, defs);
+ },
+ /**
+ * Add the image popovers' buttons:
+ * From _super:
+ * - Replacement
+ * - Removal
+ * - Alignment
+ * - Padding
+ * From this override:
+ * - Shape
+ * - Crop
+ * - Alt
+ * - Size
+ *
+ * @private
+ */
+ _addButtons: function () {
+ var self = this;
+ this._super();
+ // add all shape buttons if this option is active
+ this.context.memo('button.imageShape', function () {
+ var $el = $();
+ _.each(['rounded', 'rounded-circle', 'shadow', 'img-thumbnail'], function (shape) {
+ $el = $el.add(self._createToggleButton(null, self.options.icons.imageShape[shape], self.lang.image.imageShape[shape], shape));
+ });
+ return $el;
+ });
+ this.context.memo('button.cropImage', function () {
+ return self.context.invoke('buttons.button', {
+ contents: self.ui.icon(self.options.icons.cropImage),
+ tooltip: self.lang.image.cropImage,
+ click: self.context.createInvokeHandler('ImagePlugin.cropImageDialog')
+ }).render();
+ });
+ this.altBtnPrefix = '<b>' + self.lang.image.alt + '</b>';
+ this.context.memo('button.alt', function () {
+ return self.context.invoke('buttons.button', {
+ contents: self.altBtnPrefix,
+ click: self.context.createInvokeHandler('ImagePlugin.altDialg')
+ }).render();
+ });
+ this.context.memo('button.imageSizeAuto', function () {
+ return self.context.invoke('buttons.button', {
+ contents: '<span class="note-iconsize-10">' + self.lang.image.imageSizeAuto + '</span>',
+ click: self._wrapCommand(function () {
+ var target = this.context.invoke('editor.restoreTarget');
+ $(target).css({
+ width: '',
+ height: ''
+ });
+ })
+ }).render();
+ });
+ },
+ /**
+ * Return true if the target is an image.
+ *
+ * @override
+ * @param {Node} target
+ * @returns {Boolean} true if the target is an image
+ */
+ _isMedia: function (target) {
+ return dom.isImg(target);
+ },
+});
+
+_.extend(wysiwygOptions.icons, {
+ padding: 'fa fa-plus-square-o',
+ cropImage: 'fa fa-crop',
+ imageShape: {
+ rounded: 'fa fa-square',
+ 'rounded-circle': 'fa fa-circle-o',
+ shadow: 'fa fa-sun-o',
+ 'img-thumbnail': 'fa fa-picture-o',
+ },
+});
+_.extend(wysiwygTranslation.image, {
+ padding: _t('Padding'),
+ paddingList: [_t('None'), _t('Small'), _t('Medium'), _t('Large'), _t('Xl')],
+ imageSizeAuto: _t('Auto'),
+ cropImage: _t('Crop image'),
+ imageShape: {
+ rounded: _t('Shape: Rounded'),
+ 'rounded-circle': _t('Shape: Circle'),
+ shadow: _t('Shape: Shadow'),
+ 'img-thumbnail': _t('Shape: Thumbnail'),
+ },
+ alt: _t('Description:'),
+});
+
+//--------------------------------------------------------------------------
+// Video
+//--------------------------------------------------------------------------
+
+/**
+ * Return true if the node is a video.
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+dom.isVideo = function (node) {
+ node = node && !node.tagName ? node.parentNode : node;
+ return (node.tagName === "IFRAME" || node.tagName === "DIV") &&
+ (node.parentNode.className && node.parentNode.className.indexOf('media_iframe_video') !== -1 ||
+ node.className.indexOf('media_iframe_video') !== -1);
+};
+
+var VideoPlugin = AbstractMediaPlugin.extend({
+ targetType: 'video',
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Show the video target's popover.
+ *
+ * @override
+ * @param {Node} target
+ * @param {Object} mousePosition {pageX: Number, pageY: Number}
+ */
+ show: function (target, mousePosition) {
+ if (target.tagName === "DIV" && target.className.indexOf('css_editable_mode_display') !== -1) {
+ target = target.parentNode;
+ this.context.invoke('editor.saveTarget', target);
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Return true if the target is a video.
+ *
+ * @override
+ * @param {Node} target
+ */
+ _isMedia: function (target) {
+ return dom.isVideo(target);
+ },
+});
+
+//--------------------------------------------------------------------------
+// Icons: Icon Awsome (and other with themes)
+//--------------------------------------------------------------------------
+
+/**
+ * Return true if the node is an icon.
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+dom.isIcon = function (node) {
+ node = node && !node.tagName ? node.parentNode : node;
+ return node && node.className && node.className.indexOf(' fa-') !== -1;
+};
+
+var IconPlugin = AbstractMediaPlugin.extend({
+ targetType: 'icon',
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Hide this icon's popover.
+ *
+ * @override
+ */
+ hide: function () {
+ this._super();
+ this.removeSelectedClass(this.lastIcon);
+ },
+ /**
+ * Show the icon target's popover.
+ *
+ * @override
+ * @param {Node} target
+ */
+ show: function (target) {
+ this.removeSelectedClass(this.lastIcon);
+ this.lastIcon = target;
+
+ this._super.apply(this, arguments);
+
+ var $target = $(target);
+ ui.toggleBtnActive(this.$popover.find('.note-faSpin button'), $target.hasClass('fa-spin'));
+ var faSize = parseInt((($target.attr('style') || '').match(/font-size:\s*([0-9](em|px))(;|$)/) || [])[1] || 0);
+ if (!faSize) {
+ faSize = (($target.attr('class') || '').match(/(^| )fa-([0-9])x( |$)/) || [])[2];
+ }
+ ui.toggleBtnActive(this.$popover.find('.note-faSize a[data-value="fa-' + faSize + 'x"]'), true);
+ this.addSelectedClass(target);
+ },
+ /**
+ * Add a class to the current target so as to show it's selected.
+ *
+ * @param {Node} target
+ */
+ addSelectedClass: function (target) {
+ if (target) {
+ $(target).addClass('o_we_selected_image');
+ }
+ },
+ /**
+ * Remove a class to the current target so as to show it's not selected.
+ *
+ * @param {Node} target
+ */
+ removeSelectedClass: function (target) {
+ if (target) {
+ $(target).removeClass('o_we_selected_image');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add the icon popovers' buttons:
+ * From _super:
+ * - Replacement
+ * - Removal
+ * - Alignment
+ * - Padding
+ * From this override:
+ * - Size
+ * - Spin
+ *
+ * @private
+ */
+ _addButtons: function () {
+ var self = this;
+ this._super();
+ var values = [{
+ value: '',
+ string: '1x',
+ },
+ {
+ value: 'fa-2x',
+ string: '2x',
+ },
+ {
+ value: 'fa-3x',
+ string: '3x',
+ },
+ {
+ value: 'fa-4x',
+ string: '4x',
+ },
+ {
+ value: 'fa-5x',
+ string: '5x',
+ },
+ ];
+ var onFaSize = function (e) {
+ var classNames = _.map(values, function (item) {
+ return item.value;
+ }).join(' ');
+ var $target = $(self.context.invoke('editor.restoreTarget'));
+ $target.removeClass(classNames);
+ if ($(e.target).data('value')) {
+ $target.addClass($(e.target).data('value'));
+ $target.css('fontSize', '');
+ }
+ };
+ this._createDropdownButton('faSize', this.options.icons.faSize, this.lang.image.faSize, values, onFaSize);
+ this._createToggleButton('faSpin', this.options.icons.faSpin, this.lang.image.faSpin, 'fa-spin');
+ },
+ /**
+ * Return true if the target is an icon.
+ *
+ * @override
+ * @param {Node} target
+ */
+ _isMedia: function (target) {
+ return dom.isIcon(target);
+ },
+ /**
+ * Update the position of the popover in CSS.
+ *
+ * @override
+ * @param {Node} target
+ * @param {Object} mousePosition {pageX: Number, pageY: Number}
+ */
+ _popoverPosition: function (target, mousePosition) {
+ var pos = $(target).offset();
+ var posContainer = $(this.options.container).offset();
+ pos.left = pos.left - posContainer.left + this.options.POPOVER_MARGIN + parseInt($(target).css('font-size')) + 10;
+ pos.top = pos.top - posContainer.top + this.options.POPOVER_MARGIN;
+
+ var popoverPos = this._popoverFitEditor(pos.left + 10, pos.top - 15);
+ this.$popover.css({
+ display: 'block',
+ left: popoverPos.left,
+ top: popoverPos.top,
+ });
+ },
+});
+_.extend(wysiwygOptions.icons, {
+ faSize: 'fa fa-expand',
+ faSpin: 'fa fa-refresh',
+});
+_.extend(wysiwygTranslation.image, {
+ faSize: _t('Icon size'),
+ faSpin: _t('Spin'),
+});
+
+//--------------------------------------------------------------------------
+// Media Document
+//--------------------------------------------------------------------------
+
+/**
+ * Return true is the node is a document.
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+dom.isDocument = function (node) {
+ node = node && !node.tagName ? node.parentNode : node;
+ return node && (node.tagName === "A" && node.className.indexOf('o_image') !== -1);
+};
+
+var DocumentPlugin = AbstractMediaPlugin.extend({
+ targetType: 'document',
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Return true if the target is an icon.
+ *
+ * @override
+ * @param {Node} target
+ */
+ _isMedia: function (target) {
+ return dom.isDocument(target);
+ },
+ /**
+ * Update the position of the popover in CSS.
+ *
+ * @override
+ * @param {Node} target
+ * @param {Object} mousePosition {pageX: Number, pageY: Number}
+ */
+ _popoverPosition: function (target, mousePosition) {
+ var pos = $(target).offset();
+ var posContainer = $(this.options.container).offset();
+ pos.left = pos.left - posContainer.left + this.options.POPOVER_MARGIN;
+ pos.top = pos.top - posContainer.top + this.options.POPOVER_MARGIN;
+
+ var popoverPos = this._popoverFitEditor(pos.left + 10, pos.top - 15);
+ this.$popover.css({
+ display: 'block',
+ left: popoverPos.left,
+ top: popoverPos.top,
+ });
+ },
+});
+
+//--------------------------------------------------------------------------
+// Handle (hover image)
+//--------------------------------------------------------------------------
+
+var HandlePlugin = Plugins.handle.extend({
+ /**
+ * Update the handle.
+ *
+ * @param {Node} target
+ * @returns {Boolean}
+ */
+ update: function (target) {
+ if (this.context.isDisabled()) {
+ return false;
+ }
+ var isImage = dom.isImg(target);
+ var $selection = this.$handle.find('.note-control-selection');
+ this.context.invoke('imagePopover.update', target);
+ if (!isImage) {
+ return isImage;
+ }
+
+ var $target = $(target);
+ var pos = $target.offset();
+ var posContainer = $selection.closest('.note-handle').offset();
+
+ // exclude margin
+ var imageSize = {
+ w: $target.outerWidth(false),
+ h: $target.outerHeight(false)
+ };
+ $selection.css({
+ display: 'block',
+ left: pos.left - posContainer.left,
+ top: pos.top - posContainer.top,
+ width: imageSize.w,
+ height: imageSize.h,
+ }).data('target', $target); // save current target element.
+
+ var src = $target.attr('src');
+ var sizingText = imageSize.w + 'x' + imageSize.h;
+ if (src) {
+ var origImageObj = new Image();
+ origImageObj.src = src;
+ sizingText += ' (' + this.lang.image.original + ': ' + origImageObj.width + 'x' + origImageObj.height + ')';
+ }
+ $selection.find('.note-control-selection-info').text(sizingText);
+ this.context.invoke('editor.saveTarget', target);
+
+ return isImage;
+ },
+});
+
+//--------------------------------------------------------------------------
+// add to registry
+//--------------------------------------------------------------------------
+
+registry.add('MediaPlugin', MediaPlugin)
+ .add('ImagePlugin', ImagePlugin)
+ .add('VideoPlugin', VideoPlugin)
+ .add('IconPlugin', IconPlugin)
+ .add('DocumentPlugin', DocumentPlugin)
+ .add('HandlePlugin', HandlePlugin);
+
+// modules to remove from summernote
+registry.add('imagePopover', null)
+ .add('handle', null);
+
+return {
+ MediaPlugin: MediaPlugin,
+ ImagePlugin: ImagePlugin,
+ VideoPlugin: VideoPlugin,
+ IconPlugin: IconPlugin,
+ DocumentPlugin: DocumentPlugin,
+};
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/plugins.js b/addons/web_editor/static/src/js/wysiwyg/plugin/plugins.js
new file mode 100644
index 00000000000..dfd5b109e74
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/plugins.js
@@ -0,0 +1,78 @@
+odoo.define('web_editor.wysiwyg.plugins', function (require) {
+'use strict';
+
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+var wysiwygOptions = require('web_editor.wysiwyg.options');
+
+
+var plugins = _.mapObject(wysiwygOptions.modules, function (Module, pluginName) {
+ var prototype = {
+ init: function () {
+ var self = this;
+ this._super.apply(this, arguments);
+ var events = _.clone(this.events);
+ this.summernote.options.modules[pluginName].apply(this, arguments);
+ _.each(events, function (value, key) {
+ self.events[key] = value;
+ });
+ },
+ };
+ _.each(Module.prototype, function (prop, name) {
+ if (typeof prop === 'function') {
+ prototype[name] = function () {
+ return this.summernote.options.modules[pluginName].prototype[name].apply(this, arguments);
+ };
+ } else {
+ prototype[name] = prop;
+ }
+ });
+
+ var Plugin = AbstractPlugin.extend(prototype).extend({
+ destroy: function () {
+ if (this.shouldInitialize()) {
+ this._super();
+ }
+ },
+ });
+
+ // override summernote default buttons
+ registry.add(pluginName, Plugin);
+
+ return Plugin;
+});
+
+// export table plugin to convert it in module (see editor)
+
+var $textarea = $('<textarea>');
+$('body').append($textarea);
+$textarea.summernote();
+var summernote = $textarea.data('summernote');
+
+_.each(['style', 'table', 'typing', 'bullet', 'history'], function (name) {
+ var prototype = {};
+ for (var k in summernote.modules.editor[name]) {
+ prototype[k] = summernote.modules.editor[name][k];
+ }
+ plugins[name] = AbstractPlugin.extend(prototype);
+});
+
+var History = summernote.modules.editor.history.constructor;
+plugins.history.include({
+ init: function (context) {
+ this._super(context);
+ History.call(this, this.$editable);
+ },
+});
+
+try {
+ $textarea.summernote('destroy');
+} catch (e) {
+ summernote.layoutInfo.editor.remove();
+}
+$textarea.remove();
+
+
+return plugins;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/table.js b/addons/web_editor/static/src/js/wysiwyg/plugin/table.js
new file mode 100644
index 00000000000..b015faee43f
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/table.js
@@ -0,0 +1,244 @@
+odoo.define('web_editor.wysiwyg.plugin.table', function (require) {
+'use strict';
+
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var dom = $.summernote.dom;
+
+
+var TablePlugin = Plugins.table.extend({
+
+ initialize: function () {
+ this._super.apply(this, arguments);
+ var self = this;
+ // We need setTimeout to make sure to initialize after HelperPlugin and HistoryPlugin
+ setTimeout(function () {
+ // contentEditable fail for image and font in table
+ // user must use right arrow the number of space but without feedback
+ self.$editable.find('td:has(img, span.fa)').each(function () {
+ if (this.firstChild && !this.firstChild.tagName) {
+ var startSpace = self.context.invoke('HelperPlugin.getRegex', 'startSpace');
+ this.firstChild.textContent = this.firstChild.textContent.replace(startSpace, ' ');
+ }
+ if (this.lastChild && !this.lastChild.tagName) {
+ var endSpace = self.context.invoke('HelperPlugin.getRegex', 'endSpace');
+ this.lastChild.textContent = this.lastChild.textContent.replace(endSpace, ' ');
+ }
+ });
+ self.context.invoke('HistoryPlugin.clear');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a new col and
+ * wrap contents of the new cells in p elements.
+ *
+ * @see summernote library
+ *
+ * @override
+ * @param {WrappedRange} rng
+ * @param {String('left'|'right')} position
+ */
+ addCol: function (rng, position) {
+ this._super.apply(this, arguments);
+ var cell = dom.ancestor(rng.commonAncestor(), dom.isCell);
+ var table = dom.ancestor(cell, function (n) {
+ return n.tagName === 'TABLE';
+ });
+ var newColIndex = $(cell)[position === 'right' ? 'next' : 'prev']('td').index() + 1;
+ $(table).find('td:nth-child(' + newColIndex + ')').contents().wrap('<p></p>');
+ },
+ /**
+ * Add a new row and
+ * wrap contents of the new cells in p elements.
+ *
+ * @see summernote library
+ *
+ * @override
+ * @param {WrappedRange} rng
+ * @param {String('top'|'bottom')} position
+ */
+ addRow: function (rng, position) {
+ this._super.apply(this, arguments);
+ var row = dom.ancestor(rng.commonAncestor(), dom.isCell).parentElement;
+ $(row)[position === 'bottom' ? 'next' : 'prev']('tr').find('td').contents().wrap('<p></p>');
+ },
+ /**
+ * Create empty table element and
+ * wrap the contents of all cells in p elements.
+ *
+ * @see summernote library
+ *
+ * @override
+ * @param {Number} rowCount
+ * @param {Number} colCount
+ * @returns {Node} table
+ */
+ createTable: function () {
+ var table = this._super.apply(this, arguments);
+ $(table).find('td').contents().wrap('<p></p>');
+ return table;
+ },
+ /**
+ * @see summernote library
+ *
+ * @override
+ */
+ deleteCol: function () {
+ var range = this.context.invoke('editor.createRange');
+
+ // Delete the last remaining column === delete the table
+ var cell = dom.ancestor(range.commonAncestor(), dom.isCell);
+ if (cell && !cell.previousElementSibling && !cell.nextElementSibling) {
+ return this.deleteTable();
+ }
+ var neighbor = cell.previousElementSibling || cell.nextElementSibling;
+
+ this._super.apply(this, arguments);
+
+ // Put the range back on the previous or next cell after deleting
+ // to allow chain-removing
+ range = this.context.invoke('editor.createRange');
+ if (range.sc.tagName !== 'TD' && neighbor) {
+ range = this.context.invoke('editor.setRange', neighbor, 0);
+ range.normalize().select();
+ }
+ },
+ /**
+ * @see summernote library
+ *
+ * @override
+ */
+ deleteRow: function () {
+ var range = this.context.invoke('editor.createRange');
+
+ // Delete the last remaining row === delete the table
+ var row = dom.ancestor(range.commonAncestor(), function (n) {
+ return n.tagName === 'TR';
+ });
+ if (row && !row.previousElementSibling && !row.nextElementSibling) {
+ return this.deleteTable();
+ }
+ var neighbor = row.previousElementSibling || row.nextElementSibling;
+
+ this._super.apply(this, arguments);
+
+ // Put the range back on the previous or next row after deleting
+ // to allow chain-removing
+ range = this.context.invoke('editor.createRange');
+ if (range.sc.tagName !== 'TR' && neighbor) {
+ range = this.context.invoke('editor.setRange', neighbor, 0);
+ range.normalize().select();
+ }
+ },
+ /**
+ * Delete the table in range.
+ */
+ deleteTable: function () {
+ var range = this.context.invoke('editor.createRange');
+ var cell = dom.ancestor(range.commonAncestor(), dom.isCell);
+ var table = $(cell).closest('table')[0];
+
+ var point = this.context.invoke('HelperPlugin.removeBlockNode', table);
+ range = this.context.invoke('editor.setRange', point.node, point.offset);
+ range.normalize().select();
+ },
+ /**
+ * Insert a table.
+ * Note: adds <p><br></p> before/after the table if the table
+ * has nothing brefore/after it, so as to allow the carret to move there.
+ *
+ * @param {String} dim dimension of table (ex : "5x5")
+ */
+ insertTable: function (dim) {
+ var dimension = dim.split('x');
+ var table = this.createTable(dimension[0], dimension[1], this.options);
+ this.context.invoke('HelperPlugin.insertBlockNode', table);
+ var p;
+ if (!table.previousElementSibling) {
+ p = this.document.createElement('p');
+ $(p).append(this.document.createElement('br'));
+ $(table).before(p);
+ }
+ if (!table.nextElementSibling) {
+ p = this.document.createElement('p');
+ $(p).append(this.document.createElement('br'));
+ $(table).after(p);
+ }
+ var range = this.context.invoke('editor.setRange', $(table).find('td')[0], 0);
+ range.normalize().select();
+ this.context.invoke('editor.saveRange');
+ },
+});
+
+
+var TablePopover = Plugins.tablePopover.extend({
+ events: _.defaults({
+ 'summernote.scroll': '_onScroll',
+ }, Plugins.tablePopover.prototype.events),
+
+ /**
+ * Update the table's popover and its position.
+ *
+ * @override
+ * @param {Node} target
+ * @returns {false|Node} the selected cell (on which to display the popover)
+ */
+ update: function (target) {
+ if (!target || this.context.isDisabled()) {
+ return false;
+ }
+ var cell = dom.ancestor(target, dom.isCell);
+ if (!!cell && this.options.isEditableNode(cell)) {
+ var pos = $(cell).offset();
+ var posContainer = $(this.options.container).offset();
+ pos.left = pos.left - posContainer.left + 10;
+ pos.top = pos.top - posContainer.top + $(cell).outerHeight() - 4;
+
+ this.lastPos = this.context.invoke('HelperPlugin.makePoint', target, $(target).offset());
+
+ this.$popover.css({
+ display: 'block',
+ left: pos.left,
+ top: pos.top,
+ });
+ } else {
+ this.hide();
+ }
+ return cell;
+ },
+ /**
+ * Update the target table and its popover.
+ *
+ * @private
+ */
+ _onScroll: function () {
+ var range = this.context.invoke('editor.createRange');
+ var target = dom.ancestor(range.sc, dom.isCell);
+ if (!target || target === this.editable) {
+ return;
+ }
+ if (this.lastPos && this.lastPos.target === target && $(target).offset()) {
+ var newTop = $(target).offset().top;
+ var movement = this.lastPos.offset.top - newTop;
+ if (movement && this.mousePosition) {
+ this.mousePosition.pageY -= movement;
+ }
+ }
+ return this.update(target);
+ },
+});
+
+
+registry.add('TablePlugin', TablePlugin);
+registry.add('TablePopover', TablePopover);
+registry.add('tablePopover', null);
+
+return TablePlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/text.js b/addons/web_editor/static/src/js/wysiwyg/plugin/text.js
new file mode 100644
index 00000000000..d3ac908c507
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/text.js
@@ -0,0 +1,799 @@
+odoo.define('web_editor.wysiwyg.plugin.text', function (require) {
+'use strict';
+
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var dom = $.summernote.dom;
+dom.isAnchor = function (node) {
+ return (node.tagName === 'A' || node.tagName === 'BUTTON' || $(node).hasClass('btn')) &&
+ !$(node).hasClass('fa') && !$(node).hasClass('o_image');
+};
+
+
+var TextPlugin = AbstractPlugin.extend({
+ events: {
+ 'summernote.paste': '_onPaste',
+ },
+
+ // See: https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#removeformat-candidate
+ formatTags: [
+ 'abbr',
+ 'acronym',
+ 'b',
+ 'bdi',
+ 'bdo',
+ 'big',
+ 'blink',
+ 'cite',
+ 'code',
+ 'dfn',
+ 'em',
+ 'font',
+ 'i',
+ 'ins',
+ 'kbd',
+ 'mark',
+ 'nobr',
+ 'q',
+ 's',
+ 'samp',
+ 'small',
+ 'span',
+ 'strike',
+ 'strong',
+ 'sub',
+ 'sup',
+ 'tt',
+ 'u',
+ 'var',
+ ],
+ tab: '\u00A0\u00A0\u00A0\u00A0',
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Insert a Horizontal Rule element (hr).
+ */
+ insertHR: function () {
+ var self = this;
+ var hr = this.document.createElement('hr');
+ this.context.invoke('HelperPlugin.insertBlockNode', hr);
+ var point = this.context.invoke('HelperPlugin.makePoint', hr, 0);
+ point = dom.nextPointUntil(point, function (pt) {
+ return pt.node !== hr && !self.options.isUnbreakableNode(pt.node);
+ }) || point;
+ var range = $.summernote.range.create(point.node, point.offset);
+ range.select();
+ },
+ /**
+ * Insert a TAB (4 non-breakable spaces).
+ */
+ insertTab: function () {
+ this.context.invoke('HelperPlugin.insertTextInline', this.tab);
+ },
+ /**
+ * Paste nodes or their text content into the editor.
+ *
+ * @param {Node[]} nodes
+ * @param {Boolean} textOnly true to allow only dropping plain text
+ */
+ pasteNodes: function (nodes, textOnly) {
+ if (!nodes.length) {
+ return;
+ }
+ nodes = textOnly ? this.document.createTextNode($(nodes).text()) : nodes;
+ nodes = this._mergeAdjacentULs(nodes);
+
+ var point = this._getPastePoint();
+ // Prevent pasting HTML within a link:
+ point = textOnly ? point : dom.nextPointUntil(point, this._isPointInAnchor.bind(this));
+
+ this._insertNodesAt(nodes, point);
+
+ var start = nodes[nodes.length - 1];
+ this.context.invoke('editor.setRange', start, dom.nodeLength(start)).normalize().select();
+ },
+ /**
+ * Prepare clipboard data for safe pasting into the editor.
+ *
+ * @see clipboardWhitelist
+ * @see clipboardBlacklist
+ *
+ * @param {DOMString} clipboardData
+ * @returns {Node[]}
+ */
+ prepareClipboardData: function (clipboardData) {
+ var $clipboardData = this._removeIllegalClipboardElements($(clipboardData));
+
+ var $all = $clipboardData.find('*').addBack();
+ $all.filter('table').addClass('table table-bordered');
+ this._wrapTDContents($all.filter('td'));
+ this._fillEmptyBlocks($all);
+ this._removeIllegalClipboardAttributes($all);
+ $all.filter('a').removeClass();
+ $all.filter('img').css('max-width', '100%');
+
+ return $clipboardData.toArray();
+ },
+ /**
+ * Format a 'format' block: change its tagName (eg: p -> h1).
+ *
+ * @param {string} tagName
+ * P, H1, H2, H3, H4, H5, H6, BLOCKQUOTE, PRE
+ */
+ formatBlock: function (tagName) {
+ var self = this;
+ var r = this.context.invoke('editor.createRange');
+ if (
+ !r ||
+ !this.$editable.has(r.sc).length ||
+ !this.$editable.has(r.ec).length ||
+ this.options.isUnbreakableNode(r.sc)
+ ) {
+ return;
+ }
+ var nodes = this.context.invoke('HelperPlugin.getSelectedNodes');
+ nodes = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes);
+ if (!nodes.length) {
+ var node = this.context.invoke('editor.createRange').sc;
+ if (node.tagName === 'BR' || dom.isText(node)) {
+ node = node.parentNode;
+ }
+ nodes = [node];
+ }
+ var changedNodes = [];
+ _.each(nodes, function (node) {
+ var newNode = self.document.createElement(tagName);
+ $(newNode).append($(node).contents());
+ var attributes = $(node).prop("attributes");
+ _.each(attributes, function (attr) {
+ $(newNode).attr(attr.name, attr.value);
+ });
+ $(node).replaceWith(newNode);
+ changedNodes.push(newNode);
+ });
+
+ // Select all formatted nodes
+ if (changedNodes.length) {
+ var lastNode = changedNodes[changedNodes.length - 1];
+ var startNode = changedNodes[0].firstChild || changedNodes[0];
+ var endNode = lastNode.lastChild || lastNode;
+ var range = this.context.invoke('editor.setRange', startNode, 0, endNode, dom.nodeLength(endNode));
+ range.select();
+ }
+ },
+ /**
+ * Change the paragraph alignment of a 'format' block.
+ *
+ * @param {string} style
+ * justifyLeft, justifyCenter, justifyRight, justifyFull
+ */
+ formatBlockStyle: function (style) {
+ var self = this;
+ var nodes = this.context.invoke('HelperPlugin.getSelectedNodes');
+ nodes = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes);
+ var align = style === 'justifyLeft' ? 'left' :
+ style === 'justifyCenter' ? 'center' :
+ style === 'justifyRight' ? 'right' : 'justify';
+ _.each(nodes, function (node) {
+ if (dom.isText(node)) {
+ return;
+ }
+ var textAlign = self.window.getComputedStyle(node).textAlign;
+ if (align !== textAlign) {
+ if (align !== self.window.getComputedStyle(node.parentNode).textAlign) {
+ $(node).css('text-align', align);
+ } else {
+ $(node).css('text-align', '');
+ }
+ }
+ });
+ this.editable.normalize();
+ },
+ /**
+ * (Un-)format text: make it bold, italic, ...
+ *
+ * @param {string} tagName
+ * bold, italic, underline, strikethrough, superscript, subscript
+ */
+ formatText: function (tagName) {
+ var self = this;
+ var tag = {
+ bold: 'B',
+ italic: 'I',
+ underline: 'U',
+ strikethrough: 'S',
+ superscript: 'SUP',
+ subscript: 'SUB',
+ } [tagName];
+ if (!tag) {
+ throw new Error(tagName);
+ }
+
+ var range = this.context.invoke('editor.createRange');
+ if (!range || !this.$editable.has(range.sc).length || !this.$editable.has(range.ec).length) {
+ return;
+ }
+ if (range.isCollapsed()) {
+ var br;
+ if (range.sc.tagName === 'BR') {
+ br = range.sc;
+ } else if (range.sc.firstChild && range.sc.firstChild.tagName === 'BR') {
+ br = range.sc.firstChild;
+ }
+ if (br) {
+ var emptyText = this.document.createTextNode('\u200B');
+ $(br).before(emptyText).remove();
+ range = this.context.invoke('editor.setRange', emptyText, 0, emptyText, 1);
+ } else {
+ this.context.invoke('HelperPlugin.insertTextInline', '\u200B');
+ range.eo += 1;
+ }
+ range.select();
+ }
+
+ var nodes = this.context.invoke('HelperPlugin.getSelectedNodes');
+ var texts = this.context.invoke('HelperPlugin.filterLeafChildren', nodes);
+ var formatted = this.context.invoke('HelperPlugin.filterFormatAncestors', nodes);
+
+ var start = this.context.invoke('HelperPlugin.firstLeaf', nodes[0]);
+ var end = this.context.invoke('HelperPlugin.lastLeaf', nodes[nodes.length - 1]);
+
+ function containsOnlySelectedText(node) {
+ return _.all(node.childNodes, function (n) {
+ return _.any(texts, function (t) {
+ return n === t && !(dom.isText(n) && n.textContent === '');
+ }) && containsOnlySelectedText(n);
+ });
+ }
+
+ function containsAllSelectedText(node) {
+ return _.all(texts, function (t) {
+ return _.any(node.childNodes, function (n) {
+ return n === t && !(dom.isText(n) && n.textContent === '') || containsAllSelectedText(n);
+ });
+ });
+ }
+
+ var nodeAlreadyStyled = [];
+ var toStyled = [];
+ var notStyled = _.filter(texts, function (text, index) {
+ if (toStyled.indexOf(text) !== -1 || nodeAlreadyStyled.indexOf(text) !== -1) {
+ return;
+ }
+ nodeAlreadyStyled.push(text);
+
+ end = text;
+
+ var styled = dom.ancestor(text, function (node) {
+ return node.tagName === tag;
+ });
+ if (styled) {
+ if (
+ !/^\u200B$/.test(text.textContent) &&
+ containsAllSelectedText(styled) &&
+ containsOnlySelectedText(styled)
+ ) {
+ // Unwrap all contents
+ nodes = $(styled).contents();
+ $(styled).before(nodes).remove();
+ nodeAlreadyStyled.push.apply(nodeAlreadyStyled, nodes);
+ end = _.last(nodeAlreadyStyled);
+ } else {
+ var options = {
+ isSkipPaddingBlankHTML: true,
+ isNotSplitEdgePoint: true,
+ };
+
+ if (
+ nodeAlreadyStyled.indexOf(text.nextSibling) === -1 &&
+ !dom.isRightEdgeOf(text, styled)
+ ) {
+ options.nextText = false;
+ var point = self.context.invoke('HelperPlugin.makePoint', text, dom.nodeLength(text));
+ if (dom.isMedia(text)) {
+ point = dom.nextPoint(point);
+ }
+ var next = self.context.invoke('HelperPlugin.splitTree', styled, point, options);
+ nodeAlreadyStyled.push(next);
+ }
+ if (
+ nodeAlreadyStyled.indexOf(text.previousSibling) === -1 &&
+ !dom.isLeftEdgeOf(text, styled)
+ ) {
+ options.nextText = true;
+ var textPoint = self.context.invoke('HelperPlugin.makePoint', text, 0);
+ text = self.context.invoke('HelperPlugin.splitTree', styled, textPoint, options);
+ nodeAlreadyStyled.push(text);
+ if (index === 0) {
+ start = text;
+ }
+ end = text;
+ }
+
+ var toRemove = dom.ancestor(text, function (n) {
+ return n.tagName === tag;
+ });
+ if (toRemove) {
+ // Remove generated empty elements
+ if (
+ toRemove.nextSibling &&
+ self.context.invoke('HelperPlugin.isBlankNode', toRemove.nextSibling)
+ ) {
+ $(toRemove.nextSibling).remove();
+ }
+ if (
+ toRemove.previousSibling &&
+ self.context.invoke('HelperPlugin.isBlankNode', toRemove.previousSibling)
+ ) {
+ $(toRemove.previousSibling).remove();
+ }
+
+ // Unwrap the element
+ nodes = $(toRemove).contents();
+ $(toRemove).before(nodes).remove();
+ nodeAlreadyStyled.push.apply(nodeAlreadyStyled, nodes);
+ end = _.last(nodeAlreadyStyled);
+ }
+ }
+ }
+
+ if (dom.ancestor(text, function (node) {
+ return toStyled.indexOf(node) !== -1;
+ })) {
+ return;
+ }
+
+ var node = text;
+ while (
+ node && node.parentNode &&
+ formatted.indexOf(node) === -1 &&
+ formatted.indexOf(node.parentNode) === -1
+ ) {
+ node = node.parentNode;
+ }
+ if (node !== text) {
+ if (containsAllSelectedText(node)) {
+ toStyled.push.apply(toStyled, texts);
+ node = text;
+ } else if (!containsOnlySelectedText(node)) {
+
+ node = text;
+ }
+ }
+
+ if (toStyled.indexOf(node) === -1) {
+ toStyled.push(node);
+ }
+ return !styled;
+ });
+
+ toStyled = _.uniq(toStyled);
+
+ if (notStyled.length) {
+ nodes = [];
+ var toMerge = [];
+ _.each(toStyled, function (node) {
+ var next = true;
+ if (node.nextSibling && node.nextSibling.tagName === tag) {
+ $(node.nextSibling).prepend(node);
+ next = false;
+ }
+ if (node.previousSibling && node.previousSibling.tagName === tag) {
+ $(node.previousSibling).append(node);
+ }
+ if (node.parentNode && node.parentNode.tagName !== tag) {
+ var styled = self.document.createElement(tag);
+ if (node.tagName) {
+ $(styled).append(node.childNodes);
+ $(node).append(styled);
+ } else {
+ $(node).before(styled);
+ styled.appendChild(node);
+ }
+ }
+ // Add adjacent nodes with same tagName to list of nodes to merge
+ if (
+ node.parentNode && node.parentNode[next ? 'nextSibling' : 'previousSibling'] &&
+ node.parentNode.tagName === node.parentNode[next ? 'nextSibling' : 'previousSibling'].tagName
+ ) {
+ toMerge.push(next ? node.parentNode : node.parentNode.previousSibling);
+ }
+ });
+ // Merge what needs merging
+ while (toMerge.length) {
+ this.context.invoke('HelperPlugin.deleteEdge', toMerge.pop(), 'next');
+ }
+ }
+
+ range = this.context.invoke('editor.setRange', start, 0, end, dom.nodeLength(end));
+
+ if (range.sc === range.ec && range.sc.textContent === '\u200B') {
+ range.so = range.eo = 1;
+ }
+
+ range.select();
+ this.editable.normalize();
+ },
+ /**
+ * Remove format on the current range.
+ *
+ * @see _isParentRemoveFormatCandidate
+ */
+ removeFormat: function () {
+ this._selectCurrentIfCollapsed();
+ var selectedText = this.context.invoke('HelperPlugin.getSelectedText');
+ var selectedIcons = this.context.invoke('HelperPlugin.getSelectedNodes');
+ selectedIcons = _.filter(selectedIcons, dom.isIcon);
+ if (!selectedText.length && !selectedIcons.length) {
+ return;
+ }
+ _.each(selectedIcons, this._removeIconFormat.bind(this));
+ _.each(selectedText, this._removeTextFormat.bind(this));
+ var startNode = selectedText[0];
+ var endNode = selectedText[selectedText.length - 1];
+ this.context.invoke('editor.setRange', startNode, 0, endNode, dom.nodeLength(endNode)).select();
+ this.editable.normalize();
+ this.context.invoke('editor.saveRange');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Remove the non-whitelisted or blacklisted
+ * top level elements from clipboard data.
+ *
+ * @see clipboardWhitelist
+ * @see clipboardBlacklist
+ *
+ * @private
+ * @param {JQuery} $clipboardData
+ * @returns {Object} {$clipboardData: JQuery, didRemoveNodes: Boolean}
+ */
+ _cleanClipboardRoot: function ($clipboardData) {
+ var didRemoveNodes = false;
+ var whiteList = this._clipboardWhitelist();
+ var blackList = this._clipboardBlacklist();
+ var $fakeParent = $(this.document.createElement('div'));
+ _.each($clipboardData, function (node) {
+ var isWhitelisted = dom.isText(node) || $(node).filter(whiteList.join(',')).length;
+ var isBlacklisted = $(node).filter(blackList.join(',')).length;
+ if (!isWhitelisted || isBlacklisted) {
+ $fakeParent.append(node.childNodes);
+ didRemoveNodes = true;
+ } else {
+ $fakeParent.append(node);
+ }
+ });
+ return {
+ $clipboardData: $fakeParent.contents(),
+ didRemoveNodes: didRemoveNodes,
+ };
+ },
+ /**
+ * Return a list of jQuery selectors for prohibited nodes on paste.
+ *
+ * @private
+ * @returns {String[]}
+ */
+ _clipboardBlacklist: function () {
+ return ['.Apple-interchange-newline'];
+ },
+ /**
+ * Return a list of jQuery selectors for exclusively authorized nodes on paste.
+ *
+ * @private
+ * @returns {String[]}
+ */
+ _clipboardWhitelist: function () {
+ var listSels = ['ul', 'ol', 'li'];
+ var styleSels = ['i', 'b', 'u', 'em', 'strong'];
+ var tableSels = ['table', 'th', 'tbody', 'tr', 'td'];
+ var miscSels = ['img', 'br', 'a', '.fa'];
+ return this.options.styleTags.concat(listSels, styleSels, tableSels, miscSels);
+ },
+ /**
+ * Return a list of attribute names that are exclusively authorized on paste.
+ *
+ * @private
+ * @returns {String[]}
+ */
+ _clipboardWhitelistAttr: function () {
+ return ['class', 'href', 'src'];
+ },
+ /**
+ * Fill up empty block elements with BR elements so the carret can enter them.
+ *
+ * @private
+ * @param {JQuery} $els
+ */
+ _fillEmptyBlocks: function ($els) {
+ var self = this;
+
+ $els.filter(function (i, n) {
+ return self.context.invoke('HelperPlugin.isNodeBlockType', n) && !n.childNodes;
+ }).append(this.document.createElement('br'));
+ },
+ /**
+ * Get all non-whitelisted or blacklisted elements from clipboard data.
+ *
+ * @private
+ * @param {JQuery} $clipboardData
+ * @returns {JQuery}
+ */
+ _filterIllegalClipboardElements: function ($clipboardData) {
+ return $clipboardData.find('*').addBack()
+ .not(this._clipboardWhitelist().join(','))
+ .addBack(this._clipboardBlacklist().join(','))
+ .filter(function () {
+ return !dom.isText(this);
+ });
+ },
+ /**
+ * Get a legal point to paste at, from the current range's start point.
+ *
+ * @private
+ * @returns {Object} {node: Node, offset: Number}
+ */
+ _getPastePoint: function () {
+ var point = this.context.invoke('editor.createRange').getStartPoint();
+ var offsetChild = point.node.childNodes[point.offset];
+ point = offsetChild ? this.context.invoke('HelperPlugin.makePoint', offsetChild, 0) : point;
+ return dom.nextPointUntil(point, this._isPastePointLegal.bind(this));
+ },
+ /**
+ * Insert nodes at a point. Insert them inline if the first node is inline
+ * and pasting inline is legal at that point.
+ *
+ * @private
+ * @param {Node[]} nodes
+ * @param {Object} point {node: Node, offset: Number}
+ */
+ _insertNodesAt: function (nodes, point) {
+ var canInsertInline = dom.isText(point.node) || point.node.tagName === 'BR' || dom.isMedia(point.node);
+ var $fakeParent = $(this.document.createElement('div'));
+ $fakeParent.append(nodes);
+ if (dom.isInline(nodes[0]) && canInsertInline) {
+ point.node = point.node.tagName ? point.node : point.node.splitText(point.offset);
+ $(point.node).before($fakeParent.contents());
+ } else {
+ this.context.invoke('HelperPlugin.insertBlockNode', $fakeParent[0]);
+ }
+ $fakeParent.contents().unwrap();
+ },
+ /**
+ * Return true if the parent of the given node is a removeFormat candidate:
+ * - It is a removeFormat candidate as defined by W3C
+ * - It is contained within the editable area
+ * - It is not unbreakable
+ *
+ * @see formatTags the list of removeFormat candidates as defined by W3C
+ *
+ * @private
+ * @param {Node} node
+ */
+ _isParentRemoveFormatCandidate: function (node) {
+ var parent = node.parentNode;
+ if (!parent) {
+ return false;
+ }
+ var isEditableOrAbove = parent && (parent === this.editable || $.contains(parent, this.editable));
+ var isUnbreakable = parent && this.options.isUnbreakableNode(parent);
+ var isRemoveFormatCandidate = parent && parent.tagName && this.formatTags.indexOf(parent.tagName.toLowerCase()) !== -1;
+ return parent && !isEditableOrAbove && !isUnbreakable && isRemoveFormatCandidate;
+ },
+ /**
+ * Return true if it's legal to paste nodes at the given point:
+ * if the point is not within a void node and the point is not unbreakable.
+ *
+ * @private
+ * @param {Object} point {node: Node, offset: Number}
+ * @returns {Boolean}
+ */
+ _isPastePointLegal: function (point) {
+ var node = point.node;
+ var isWithinVoid = false;
+ if (node.parentNode) {
+ isWithinVoid = dom.isVoid(node.parentNode) || $(node.parentNode).filter('.fa').length;
+ }
+ return !isWithinVoid && !this.options.isUnbreakableNode(point.node);
+ },
+ /**
+ * @private
+ * @param {Object} point {node: Node, offset: Number}
+ * @returns {Boolean}
+ */
+ _isPointInAnchor: function (point) {
+ var ancestor = dom.ancestor(point.node, dom.isAnchor);
+ return !ancestor || ancestor === this.editable;
+ },
+ /**
+ * Check a list of nodes and merges all adjacent ULs together:
+ * [ul, ul, p, ul, ul] will return [ul, p, ul], with the li's of
+ * nodes[1] and nodes[4] appended to nodes[0] and nodes[3].
+ *
+ * @private
+ * @param {Node[]} nodes
+ * @return {Node[]} the remaining, merged nodes
+ */
+ _mergeAdjacentULs: function (nodes) {
+ var res = [];
+ var prevNode;
+ _.each(nodes, function (node) {
+ prevNode = res[res.length - 1];
+ if (prevNode && node.tagName === 'UL' && prevNode.tagName === 'UL') {
+ $(prevNode).append(node.childNodes);
+ } else {
+ res.push(node);
+ }
+ });
+ return res;
+ },
+ /**
+ * Remove an icon's format (colors, font size).
+ *
+ * @private
+ * @param {Node} icon
+ */
+ _removeIconFormat: function (icon) {
+ $(icon).css({
+ color: '',
+ backgroundColor: '',
+ fontSize: '',
+ });
+ var reColorClasses = /(^|\s+)(bg|text)-\S*|/g;
+ icon.className = icon.className.replace(reColorClasses, '').trim();
+ },
+ /**
+ * Remove non-whitelisted attributes from clipboard.
+ *
+ * @private
+ * @param {JQuery} $els
+ */
+ _removeIllegalClipboardAttributes: function ($els) {
+ var self = this;
+ $els.each(function () {
+ var $node = $(this);
+ _.each(_.pluck(this.attributes, 'name'), function (attribute) {
+ if (self._clipboardWhitelistAttr().indexOf(attribute) === -1) {
+ $node.removeAttr(attribute);
+ }
+ });
+ }).removeClass('o_editable o_not_editable');
+ },
+ /**
+ * Remove non-whitelisted and blacklisted elements from clipboard data.
+ *
+ * @private
+ * @param {JQuery} $clipboardData
+ * @returns {JQuery}
+ */
+ _removeIllegalClipboardElements: function ($clipboardData) {
+ var root = true;
+ $clipboardData = $clipboardData.not('meta').not('style').not('script');
+ var $badNodes = this._filterIllegalClipboardElements($clipboardData);
+
+ do {
+ if (root) {
+ root = false;
+ var cleanData = this._cleanClipboardRoot($clipboardData);
+ $clipboardData = cleanData.$clipboardData;
+ root = cleanData.didRemoveNodes;
+ } else {
+ this._removeNodesPreserveContents($badNodes);
+ }
+
+ $badNodes = this._filterIllegalClipboardElements($clipboardData);
+ } while ($badNodes.length);
+ return $clipboardData;
+ },
+ /**
+ * Remove nodes from the DOM while preserving their contents if any.
+ *
+ * @private
+ * @param {JQuery} $nodes
+ */
+ _removeNodesPreserveContents: function ($nodes) {
+ var $contents = $nodes.contents();
+ if ($contents.length) {
+ $contents.unwrap();
+ } else {
+ $nodes.remove();
+ }
+ },
+ /**
+ * Remove a text node's format: remove its style parents (b, i, u, ...).
+ *
+ * @private
+ * @param {Node} textNode
+ */
+ _removeTextFormat: function (textNode) {
+ var node = textNode;
+ while (this._isParentRemoveFormatCandidate(node)) {
+ this.context.invoke('HelperPlugin.splitAtNodeEnds', node);
+ $(node.parentNode).before(node).remove();
+ }
+ },
+ /**
+ * Select the whole current node if the range is collapsed
+ *
+ * @private
+ */
+ _selectCurrentIfCollapsed: function () {
+ var range = this.context.invoke('editor.createRange');
+ if (!range.isCollapsed()) {
+ return;
+ }
+ this.context.invoke('editor.setRange', range.sc, 0, range.sc, dom.nodeLength(range.sc)).select();
+ this.context.invoke('editor.saveRange');
+ },
+ /**
+ * Prevent inline nodes directly in TDs by wrapping them in P elements.
+ *
+ * @private
+ * @param {JQuery} $tds
+ */
+ _wrapTDContents: function ($tds) {
+ var self = this;
+ var $inlinesInTD = $tds.contents().filter(function () {
+ return !self.context.invoke('HelperPlugin.isNodeBlockType', this);
+ });
+ var parentsOfInlinesInTD = [];
+ _.each($inlinesInTD, function (n) {
+ parentsOfInlinesInTD.push(self.context.invoke('HelperPlugin.firstBlockAncestor', n));
+ });
+ $($.unique(parentsOfInlinesInTD)).wrapInner(this.document.createElement('p'));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handle paste events to permit cleaning/sorting of the data before pasting.
+ *
+ * @private
+ * @param {SummernoteEvent} se
+ * @param {jQueryEvent} e
+ */
+ _onPaste: function (se, e) {
+ se.preventDefault();
+ se.stopImmediatePropagation();
+ e.preventDefault();
+ e.stopImmediatePropagation();
+
+ this.context.invoke('editor.beforeCommand');
+
+ // Clean up
+ var clipboardData = e.originalEvent.clipboardData.getData('text/html');
+ if (clipboardData) {
+ clipboardData = this.prepareClipboardData(clipboardData);
+ } else {
+ clipboardData = e.originalEvent.clipboardData.getData('text/plain');
+ // get that text as an array of text nodes separated by <br> where needed
+ var allNewlines = /\n/g;
+ clipboardData = $('<p>' + clipboardData.replace(allNewlines, '<br>') + '</p>').contents().toArray();
+ }
+
+ // Delete selection
+ this.context.invoke('HelperPlugin.deleteSelection');
+
+ // Insert the nodes
+ this.pasteNodes(clipboardData);
+ this.context.invoke('HelperPlugin.normalize');
+ this.context.invoke('editor.saveRange');
+
+ this.context.invoke('editor.afterCommand');
+ },
+});
+
+registry.add('TextPlugin', TextPlugin);
+
+return TextPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/toolbar.js b/addons/web_editor/static/src/js/wysiwyg/plugin/toolbar.js
new file mode 100644
index 00000000000..bc7a9f7abbf
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/toolbar.js
@@ -0,0 +1,57 @@
+odoo.define('web_editor.wysiwyg.plugin.toolbar', function (require) {
+'use strict';
+
+var Plugins = require('web_editor.wysiwyg.plugins');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var dom = $.summernote.dom;
+
+
+var ToolbarPlugin = Plugins.toolbar.extend({
+ events: {
+ 'summernote.mouseup': 'update',
+ 'summernote.keyup': 'update',
+ 'summernote.change': 'update',
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ initialize: function () {
+ this._super();
+ this.update();
+ },
+ /**
+ * Update the toolbar (enabled/disabled).
+ */
+ update: function () {
+ var $btn = this.$toolbar.children().not('.note-history, .note-view').find('button');
+ var target = this.context.invoke('editor.restoreTarget') || this.context.invoke('editor.createRange').sc;
+
+ if (!target || !$.contains(this.editable, target) || !this.options.isEditableNode(target)) {
+ $btn.addClass('o_disabled');
+ return;
+ }
+
+ $btn.removeClass('o_disabled');
+
+ if (!target || !this.options.displayPopover(target)) {
+ $btn.addClass('o_disabled');
+ }
+
+ if (dom.ancestor(target, dom.isMedia)) {
+ this.$toolbar.children('.note-style, .note-font, .note-para, .note-table').addClass('o_disabled');
+
+ $btn.find('.fa-file-image-o').parent().removeClass('o_disabled');
+ if (dom.ancestor(target, dom.isFont)) {
+ this.$toolbar.children('.note-color').removeClass('o_disabled');
+
+ }
+ }
+ },
+});
+
+registry.add('toolbar', ToolbarPlugin);
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/transform.js b/addons/web_editor/static/src/js/wysiwyg/plugin/transform.js
new file mode 100644
index 00000000000..f7fa5b860f2
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/transform.js
@@ -0,0 +1,85 @@
+odoo.define('web_editor.wysiwyg.plugin.transform', function (require) {
+'use strict';
+
+var core = require('web.core');
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+var wysiwygTranslation = require('web_editor.wysiwyg.translation');
+var wysiwygOptions = require('web_editor.wysiwyg.options');
+
+var _t = core._t;
+
+
+var TransformPlugin = AbstractPlugin.extend({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Manages transformations on a media.
+ */
+ transform: function () {
+ var $image = $(this.context.invoke('editor.restoreTarget'));
+
+ if ($image.data('transfo-destroy')) {
+ $image.removeData('transfo-destroy');
+ return;
+ }
+
+ $image.transfo(); // see web_editor/static/lib/jQuery.transfo.js
+
+ var mouseup = function () {
+ $('.note-popover button[data-event="transform"]').toggleClass('active', $image.is('[style*="transform"]'));
+ };
+ $(document).on('mouseup', mouseup);
+
+ var mousedown = this._wrapCommand(function (event) {
+ if (!$(event.target).closest('.transfo-container').length) {
+ $image.transfo('destroy');
+ $(document).off('mousedown', mousedown).off('mouseup', mouseup);
+ }
+ if ($(event.target).closest('.note-popover').length) {
+ var transformStyles = self.context.invoke('HelperPlugin.getRegex', '', 'g', '[^;]*transform[\\w:]*;?');
+ $image.data('transfo-destroy', true).attr('style', ($image.attr('style') || '').replace(transformStyles, ''));
+ }
+ });
+ $(document).on('mousedown', mousedown);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds the transform buttons.
+ *
+ * @override
+ */
+ _addButtons: function () {
+ var self = this;
+ this._super();
+
+ this.context.memo('button.transform', function () {
+ return self.context.invoke('buttons.button', {
+ contents: self.ui.icon(self.options.icons.transform),
+ tooltip: self.lang.image.transform,
+ click: self.context.createInvokeHandler('TransformPlugin.transform'),
+ }).render();
+ });
+ },
+
+});
+
+
+_.extend(wysiwygOptions.icons, {
+ transform: 'fa fa-object-ungroup',
+});
+_.extend(wysiwygTranslation.image, {
+ transform: _t('Transform the picture (click twice to reset transformation)'),
+});
+
+registry.add('TransformPlugin', TransformPlugin);
+
+return TransformPlugin;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js b/addons/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js
new file mode 100644
index 00000000000..81a90d55413
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin/unbreakable.js
@@ -0,0 +1,360 @@
+odoo.define('web_editor.wysiwyg.plugin.unbreakable', function (require) {
+'use strict';
+
+var AbstractPlugin = require('web_editor.wysiwyg.plugin.abstract');
+var registry = require('web_editor.wysiwyg.plugin.registry');
+
+var dom = $.summernote.dom;
+
+//--------------------------------------------------------------------------
+// unbreakable node preventing editing
+//--------------------------------------------------------------------------
+
+var Unbreakable = AbstractPlugin.extend({
+ events: {
+ 'wysiwyg.range .note-editable': '_onRange',
+ 'summernote.mouseup': '_onMouseUp',
+ 'summernote.keyup': '_onKeyup',
+ 'summernote.keydown': '_onKeydown',
+ // 'summernote.focusnode': '_onFocusnode', => add this event to summernote.
+ },
+
+ initialize: function () {
+ var self = this;
+ this._super.apply(this, arguments);
+ setTimeout(function () {
+ self.secureArea();
+ self.context.invoke('HistoryPlugin.clear');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Change the selection if it breaks an unbreakable node.
+ *
+ <unbreakable id="a">
+ content_1
+ <unbreakable id="b">content_2</unbreakable>
+ <allow id="c">
+ content_3
+ <unbreakable id="d">content_4</unbreakable>
+ <unbreakable id="e">
+ content_5
+ <allow id="f">content_6</allow>
+ content_7
+ </unbreakable>
+ content_8
+ </allow>
+ <unbreakable id="f">content_9</unbreakable>
+ <allow id="g">
+ content_10
+ <unbreakable id="h">content_11</unbreakable>
+ content_12
+ </allow>
+ </unbreakable>
+
+ START END RESIZE START RESIZE END
+
+ content_1 content_1 content_3 content_3 (find the first allowed node)
+ content_1 content_2 content_3 content_3
+ content_1 content_3 content_3 -
+ content_3 content_3 - - (nothing to do)
+ content_3 content_8 - - (can remove unbreakable node)
+ content_3 content_4 - content_3
+ content_3 content_5 - #d (can select the entire unbreakable node)
+ content_5 content_8 content_6 content_6
+ content_5 content_7 #e #e (select the entire unbreakable node)
+ content_6 content_8 - content_6
+ content_7 content_8 - content_8
+ content_9 content_12 content_10 -
+ *
+ * @returns {WrappedRange}
+ */
+ secureRange: function () {
+ var self = this;
+ var range = this.context.invoke('editor.createRange');
+ var isCollapsed = range.isCollapsed();
+ var needReselect = false;
+ var startPoint = range.getStartPoint();
+ var endPoint = range.getEndPoint();
+
+ // don't change the selection if the carret is just after a media in editable area
+ var prev;
+ if (
+ isCollapsed && startPoint.node.tagName && startPoint.node.childNodes[startPoint.offset] &&
+ (prev = dom.prevPoint(startPoint)) && dom.isMedia(prev.node) &&
+ this.options.isEditableNode(prev.node.parentNode)
+ ) {
+ return range;
+ }
+
+ // move the start selection to an allowed node
+ var target = startPoint.node.childNodes[startPoint.offset] || startPoint.node;
+ if (startPoint.offset && startPoint.offset === dom.nodeLength(startPoint.node)) {
+ startPoint.node = this.context.invoke('HelperPlugin.lastLeaf', startPoint.node);
+ startPoint.offset = dom.nodeLength(startPoint.node);
+ }
+ if (!dom.isMedia(target) || !this.options.isEditableNode(target)) {
+ var afterEnd = false;
+ startPoint = dom.nextPointUntil(startPoint, function (point) {
+ if (point.node === endPoint.node && point.offset === endPoint.offset) {
+ afterEnd = true;
+ }
+ return self.options.isEditableNode(point.node) && dom.isVisiblePoint(point) || !point.node;
+ });
+ if (!startPoint || !startPoint.node) { // no allowed node, search the other way
+ afterEnd = false;
+ startPoint = dom.prevPointUntil(range.getStartPoint(), function (point) {
+ return self.options.isEditableNode(point.node) && dom.isVisiblePoint(point) || !point.node;
+ });
+ }
+ if (startPoint && !startPoint.node) {
+ startPoint = null;
+ }
+ if (afterEnd) {
+ isCollapsed = true;
+ }
+ }
+
+ if (startPoint && (startPoint.node !== range.sc || startPoint.offset !== range.so)) {
+ needReselect = true;
+ range.sc = startPoint.node;
+ range.so = startPoint.offset;
+ if (isCollapsed) {
+ range.ec = range.sc;
+ range.eo = range.so;
+ }
+ }
+
+ if (startPoint && !isCollapsed) { // mouse selection or key selection with shiftKey
+ var point = endPoint;
+ endPoint = false;
+
+ // if the start point was moved after the end point
+ var toCollapse = !dom.prevPointUntil(point, function (point) {
+ return point.node === range.sc && point.offset === range.so;
+ });
+
+ if (!toCollapse) {
+ // find the first allowed ancestor
+ var commonUnbreakableParent = dom.ancestor(range.sc, function (node) {
+ return !dom.isMedia(node) && self.options.isUnbreakableNode(node);
+ });
+ if (!commonUnbreakableParent) {
+ commonUnbreakableParent = this.editable;
+ }
+
+ var lastCheckedNode;
+ if (point.offset === dom.nodeLength(point.node)) {
+ point = dom.nextPoint(point);
+ }
+
+ // move the end selection to an allowed node in the first allowed ancestor
+ endPoint = dom.prevPointUntil(point, function (point) {
+ if (point.node === range.sc && point.offset === range.so) {
+ return true;
+ }
+ if (lastCheckedNode === point.node) {
+ return false;
+ }
+
+ // select the entirety of the unbreakable node
+ if (
+ point.node.tagName && point.offset &&
+ $.contains(commonUnbreakableParent, point.node) &&
+ self.options.isUnbreakableNode(point.node)
+ ) {
+ return true;
+ }
+
+ var unbreakableParent = dom.ancestor(point.node, function (node) {
+ return !dom.isMedia(node) && self.options.isUnbreakableNode(node);
+ });
+ if (!unbreakableParent) {
+ unbreakableParent = self.editable;
+ }
+
+ if (commonUnbreakableParent !== unbreakableParent) {
+ lastCheckedNode = point.node;
+ return false;
+ }
+ lastCheckedNode = point.node;
+ if (!self.options.isEditableNode(point.node)) {
+ return false;
+ }
+ if (
+ (/\S|\u200B|\u00A0/.test(point.node.textContent) ||
+ dom.isMedia(point.node)) &&
+ dom.isVisiblePoint(point)
+ ) {
+ return true;
+ }
+ if (dom.isText(point.node)) {
+ lastCheckedNode = point.node;
+ }
+ return false;
+ });
+ }
+
+ if (!endPoint) {
+ endPoint = range.getStartPoint();
+ }
+
+ if (endPoint.node !== range.ec || endPoint.offset !== range.eo) {
+ needReselect = true;
+ range.ec = endPoint.node;
+ range.eo = endPoint.offset;
+ }
+ }
+
+ if (needReselect) {
+ range = range.select();
+ this.context.invoke('editor.saveRange');
+ }
+ return range;
+ },
+ /**
+ * Apply contentEditable false on all media.
+ *
+ * @param {DOM} [node] default is editable area
+ */
+ secureArea: function (node) {
+ this.$editable.find('o_not_editable').attr('contentEditable', 'false');
+
+ var medias = (function findMedia(node) {
+ var medias = [];
+ if (node.tagName !== 'IMG' && dom.isMedia(node)) {
+ medias.push(node);
+ } else {
+ $(node.childNodes).each(function () {
+ if (this.tagName) {
+ medias.push.apply(medias, findMedia(this));
+ }
+ });
+ }
+ return medias;
+ })(node || this.editable);
+ $(medias).addClass('o_fake_not_editable').attr('contentEditable', 'false');
+
+ $(medias).each(function () {
+ if (dom.isVideo(this) && !$(this).children('.o_fake_editable').length) {
+ // allow char insertion
+ $(this).prepend('<div class="o_fake_editable o_wysiwyg_to_remove" style="position: absolute;" contentEditable="true"/>');
+ $(this).append('<div class="o_fake_editable o_wysiwyg_to_remove" style="position: absolute;" contentEditable="true"/>');
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Trigger a focusnode event when the focus enters another node.
+ *
+ * @param {DOM} node
+ */
+ _focusNode: function (node) {
+ if (!node.tagName) {
+ node = node.parentNode;
+ }
+ if (this._focusedNode !== node) {
+ this._focusedNode = node;
+ this.context.triggerEvent('focusnode', node);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * Method called on wysiwyg.range event on the editable: secures the range, refocuses.
+ */
+ _onRange: function () {
+ var range = this.secureRange();
+ this._focusNode(range.sc);
+ },
+ /**
+ * Method called on mouseup event: secures the range, refocuses.
+ */
+ _onMouseUp: function () {
+ var range = this.secureRange();
+ this._focusNode(range.ec);
+ },
+ /**
+ * Method called on keydown event: prevents changes to unbreakable nodes.
+ *
+ * @param {SummernoteEvent} se
+ * @param {jQueryEvent} e
+ */
+ _onKeydown: function (se, e) {
+ if (!e.key || (e.key.length !== 1 && e.keyCode !== 8 && e.keyCode !== 46)) {
+ return;
+ }
+ var range;
+ // for test tour, to trigger Keydown on target (instead of use Wysiwyg.setRange)
+ if (
+ e.target !== this._focusedNode &&
+ (this.editable === e.target || $.contains(this.editable, e.target))
+ ) {
+ range = this.context.invoke('editor.createRange');
+ if (!$.contains(e.target, range.sc) && !$.contains(e.target, range.ec)) {
+ range = this.context.invoke('editor.setRange', e.target, 0);
+ range = range.normalize().select();
+ this.context.invoke('editor.saveRange');
+ this._focusNode(range.ec);
+ }
+ }
+
+ // rerange to prevent some edition.
+ // eg: if the user select with arraw and shifKey and keypress an other char
+ range = this.secureRange();
+ var target = range.getStartPoint();
+
+ if (e.keyCode === 8) { // backspace
+ if (!target || this.options.isUnbreakableNode(target.node)) {
+ e.preventDefault();
+ }
+ } else if (e.keyCode === 46) { // delete
+ target = dom.nextPointUntil(dom.nextPoint(target), dom.isVisiblePoint);
+ if (!target || this.options.isUnbreakableNode(target.node)) {
+ e.preventDefault();
+ }
+ }
+ },
+ /**
+ * Method called on keyup event: prevents selection of unbreakable nodes.
+ *
+ * @param {SummernoteEvent} se
+ * @param {jQueryEvent} se
+ */
+ _onKeyup: function (se, e) {
+ if (e.keyCode < 37 || e.keyCode > 40) {
+ return;
+ }
+ var range;
+ if (e.keyCode === 37) { // left
+ range = this.secureRange();
+ this._focusNode(range.sc);
+ } else if (e.keyCode === 39) { // right
+ range = this.secureRange();
+ this._focusNode(range.ec);
+ } else if (e.keyCode === 38) { // up
+ range = this.secureRange();
+ this._focusNode(range.sc);
+ } else { // down
+ range = this.secureRange();
+ this._focusNode(range.ec);
+ }
+ },
+});
+
+registry.add('UnbreakablePlugin', Unbreakable);
+
+return Unbreakable;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin_registry.js b/addons/web_editor/static/src/js/wysiwyg/plugin_registry.js
new file mode 100644
index 00000000000..bdd59cb41d2
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/plugin_registry.js
@@ -0,0 +1,43 @@
+odoo.define('web_editor.wysiwyg.plugin.registry', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var Registry = require('web.Registry');
+
+
+var WysiwygRegistry = Registry.extend({
+ init: function () {
+ this._super();
+ this._jobs = [];
+ this._xmlDependencies = [];
+ this._def = $.when();
+ },
+ start: function (wysiwyg) {
+ var defs = [this._def];
+ var fn;
+ while ((fn = this._jobs.shift())) {
+ defs.push(fn(wysiwyg));
+ }
+ var xmlPath;
+ while ((xmlPath = this._xmlDependencies.shift())) {
+ defs.push(ajax.loadXML(xmlPath, core.qweb));
+ }
+ if (defs.length !== 1) {
+ this._def = $.when.apply($, defs);
+ }
+ return this._def.state() === 'resolved' ? $.when() : this._def;
+ },
+ addJob: function (job) {
+ this._jobs.push(job);
+ },
+ addXmlDependency: function (xmlDependency) {
+ this._xmlDependencies.push(xmlDependency);
+ },
+ plugins: function () {
+ return _.clone(this.map);
+ },
+});
+
+return new WysiwygRegistry();
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/translation.js b/addons/web_editor/static/src/js/wysiwyg/translation.js
new file mode 100644
index 00000000000..e2f31e42293
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/translation.js
@@ -0,0 +1,161 @@
+odoo.define('web_editor.wysiwyg.translation', function (require) {
+'use strict';
+
+var core = require('web.core');
+var _t = core._t;
+
+$.summernote.lang.odoo = {
+ font: {
+ bold: _t('Bold'),
+ italic: _t('Italic'),
+ underline: _t('Underline'),
+ clear: _t('Remove Font Style'),
+ height: _t('Line Height'),
+ name: _t('Font Family'),
+ strikethrough: _t('Strikethrough'),
+ subscript: _t('Subscript'),
+ superscript: _t('Superscript'),
+ size: _t('Font Size')
+ },
+ image: {
+ image: _t('Picture'),
+ insert: _t('Insert Image'),
+ resizeFull: _t('Resize Full'),
+ resizeHalf: _t('Resize Half'),
+ resizeQuarter: _t('Resize Quarter'),
+ floatLeft: _t('Float Left'),
+ floatRight: _t('Float Right'),
+ floatNone: _t('Float None'),
+ shapeRounded: _t('Shape: Rounded'),
+ shapeCircle: _t('Shape: Circle'),
+ shapeThumbnail: _t('Shape: Thumbnail'),
+ shapeNone: _t('Shape: None'),
+ dragImageHere: _t('Drag image or text here'),
+ dropImage: _t('Drop image or Text'),
+ selectFromFiles: _t('Select from files'),
+ maximumFileSize: _t('Maximum file size'),
+ maximumFileSizeError: _t('Maximum file size exceeded.'),
+ url: _t('Image URL'),
+ remove: _t('Remove Image'),
+ original: _t('Original')
+ },
+ video: {
+ video: _t('Video'),
+ videoLink: _t('Video Link'),
+ insert: _t('Insert Video'),
+ url: _t('Video URL'),
+ providers: _t('(YouTube, Vimeo, Vine, Instagram, DailyMotion or Youku)')
+ },
+ link: {
+ link: _t('Link'),
+ insert: _t('Insert Link'),
+ unlink: _t('Unlink'),
+ edit: _t('Edit'),
+ textToDisplay: _t('Text to display'),
+ url: _t('To what URL should this link go?'),
+ openInNewWindow: _t('Open in new window')
+ },
+ table: {
+ table: _t('Table'),
+ addRowAbove: _t('Add row above'),
+ addRowBelow: _t('Add row below'),
+ addColLeft: _t('Add column left'),
+ addColRight: _t('Add column right'),
+ delRow: _t('Delete row'),
+ delCol: _t('Delete column'),
+ delTable: _t('Delete table')
+ },
+ hr: {
+ insert: _t('Insert Horizontal Rule')
+ },
+ style: {
+ style: _t('Style'),
+ p: _t('Normal'),
+ blockquote: _t('Quote'),
+ pre: _t('Code'),
+ h1: _t('Header 1'),
+ h2: _t('Header 2'),
+ h3: _t('Header 3'),
+ h4: _t('Header 4'),
+ h5: _t('Header 5'),
+ h6: _t('Header 6')
+ },
+ lists: {
+ unordered: _t('Unordered list'),
+ ordered: _t('Ordered list')
+ },
+ options: {
+ help: _t('Help'),
+ fullscreen: _t('Full Screen'),
+ codeview: _t('Code View')
+ },
+ paragraph: {
+ paragraph: _t('Paragraph'),
+ outdent: _t('Outdent'),
+ indent: _t('Indent'),
+ left: _t('Align left'),
+ center: _t('Align center'),
+ right: _t('Align right'),
+ justify: _t('Justify full')
+ },
+ color: {
+ recent: _t('Recent Color'),
+ more: _t('More Color'),
+ background: _t('Background Color'),
+ foreground: _t('Foreground Color'),
+ transparent: _t('Transparent'),
+ setTransparent: _t('Set transparent'),
+ reset: _t('Reset'),
+ resetToDefault: _t('Reset to default')
+ },
+ shortcut: {
+ shortcuts: _t('Keyboard shortcuts'),
+ close: _t('Close'),
+ textFormatting: _t('Text formatting'),
+ action: _t('Action'),
+ paragraphFormatting: _t('Paragraph formatting'),
+ documentStyle: _t('Document Style'),
+ extraKeys: _t('Extra keys')
+ },
+ help: {
+ insertParagraph: _t('Insert Paragraph'),
+ undo: _t('Undoes the last command'),
+ redo: _t('Redoes the last command'),
+ tab: _t('Tab'),
+ untab: _t('Outdent (when at the start of a line)'),
+ bold: _t('Set a bold style'),
+ italic: _t('Set a italic style'),
+ underline: _t('Set a underline style'),
+ strikethrough: _t('Set a strikethrough style'),
+ removeFormat: _t('Clean a style'),
+ justifyLeft: _t('Set left align'),
+ justifyCenter: _t('Set center align'),
+ justifyRight: _t('Set right align'),
+ justifyFull: _t('Set full align'),
+ insertUnorderedList: _t('Toggle unordered list'),
+ insertOrderedList: _t('Toggle ordered list'),
+ outdent: _t('Outdent current paragraph'),
+ indent: _t('Indent current paragraph'),
+ formatPara: _t('Change current block\'s format as a paragraph(P tag)'),
+ formatH1: _t('Change current block\'s format as H1'),
+ formatH2: _t('Change current block\'s format as H2'),
+ formatH3: _t('Change current block\'s format as H3'),
+ formatH4: _t('Change current block\'s format as H4'),
+ formatH5: _t('Change current block\'s format as H5'),
+ formatH6: _t('Change current block\'s format as H6'),
+ insertHorizontalRule: _t('Insert horizontal rule'),
+ 'linkDialog.show': _t('Show Link Dialog')
+ },
+ history: {
+ undo: _t('Undo'),
+ redo: _t('Redo')
+ },
+ specialChar: {
+ specialChar: _t('SPECIAL CHARACTERS'),
+ select: _t('Select Special characters')
+ }
+};
+
+return $.summernote.lang.odoo;
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js
new file mode 100644
index 00000000000..63d90fc3f52
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js
@@ -0,0 +1,64 @@
+odoo.define('wysiwyg.widgets.AltDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var _t = core._t;
+
+/**
+ * Let users change the alt & title of a media.
+ */
+var AltDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.alt',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/editor.xml']
+ ),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, media) {
+ options = options || {};
+ this._super(parent, _.extend({}, {
+ title: _t("Change media description and tooltip")
+ }, options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.media = media;
+ var allEscQuots = /&quot;/g;
+ this.alt = ($(this.media).attr('alt') || "").replace(allEscQuots, '"');
+ var title = $(this.media).attr('title') || $(this.media).data('original-title') || "";
+ this.tag_title = (title).replace(allEscQuots, '"');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var alt = this.$('#alt').val();
+ var title = this.$('#title').val();
+ var allNonEscQuots = /"/g;
+ $(this.media).attr('alt', alt ? alt.replace(allNonEscQuots, "&quot;") : null)
+ .attr('title', title ? title.replace(allNonEscQuots, "&quot;") : null);
+
+ this.trigger('saved', {
+ media: this.media,
+ });
+ return this._super.apply(this, arguments);
+ },
+});
+
+
+return AltDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js
new file mode 100644
index 00000000000..3538b1ad874
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/colorpicker_dialog.js
@@ -0,0 +1,563 @@
+odoo.define('wysiwyg.widgets.ColorpickerDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var utils = require('web.utils');
+var Dialog = require('web.Dialog');
+
+var _t = core._t;
+
+var ColorpickerDialog = Dialog.extend({
+ xmlDependencies: (Dialog.prototype.xmlDependencies || [])
+ .concat(['/web_editor/static/src/xml/wysiwyg_colorpicker.xml']),
+
+ template: 'wysiwyg.widgets.ColorpickerDialog',
+ events: _.extend({}, Dialog.prototype.events || {}, {
+ 'mousedown .o_color_pick_area': '_onMouseDownPicker',
+ 'mousedown .o_color_slider': '_onMouseDownSlider',
+ 'mousedown .o_opacity_slider': '_onMouseDownOpacitySlider',
+ 'change .o_color_picker_inputs' : '_onChangeInputs',
+ }),
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} [options]
+ * @param {string} [options.defaultColor='#FF0000']
+ */
+ init: function (parent, options) {
+ options = options || {};
+
+ this._super(parent, _.extend({
+ size: 'medium',
+ title: _t('Pick a color'),
+ buttons: [
+ {text: _t('Choose'), classes: 'btn-primary', close: true, click: this._onFinalPick.bind(this)},
+ {text: _t('Discard'), close: true},
+ ],
+ }, options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.pickerFlag = false;
+ this.sliderFlag = false;
+ this.opacitySliderFlag = false;
+ this.colorComponents = {};
+
+ var self = this;
+ var $body = $(document.body);
+ $body.on('mousemove.colorpicker', _.throttle(function (ev) {
+ self._onMouseMovePicker(ev);
+ self._onMouseMoveSlider(ev);
+ self._onMouseMoveOpacitySlider(ev);
+ }, 10));
+ $body.on('mouseup.colorpicker', _.throttle(function (ev) {
+ self.pickerFlag = false;
+ self.sliderFlag = false;
+ self.opacitySliderFlag = false;
+ }, 10));
+
+ this.options = _.clone(options);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$colorpickerArea = this.$('.o_color_pick_area');
+ this.$colorpickerPointer = this.$('.o_picker_pointer');
+ this.$colorSlider = this.$('.o_color_slider');
+ this.$colorSliderPointer = this.$('.o_slider_pointer');
+ this.$opacitySlider = this.$('.o_opacity_slider');
+ this.$opacitySliderPointer = this.$('.o_opacity_pointer');
+
+ var defaultColor = this.options.defaultColor || '#FF0000';
+ var rgba = ColorpickerDialog.convertColorToRgba(defaultColor);
+ if (rgba) {
+ this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity);
+ }
+ this.opened().then(this._updateUI.bind(this));
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ $(document.body).off('.colorpicker');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates input values, color preview, picker and slider pointer positions.
+ *
+ * @private
+ */
+ _updateUI: function () {
+ var self = this;
+
+ // Update inputs
+ _.each(this.colorComponents, function (value, color) {
+ self.$(_.str.sprintf('.o_%s_input', color)).val(value);
+ });
+
+ // Update preview
+ this.$('.o_color_preview').css('background-color', this.colorComponents.cssColor);
+
+ // Update picker area and picker pointer position
+ this.$colorpickerArea.css('background-color', _.str.sprintf('hsl(%s, 100%%, 50%%)', this.colorComponents.hue));
+ var top = (100 - this.colorComponents.lightness) * this.$colorpickerArea.height() / 100;
+ var left = this.colorComponents.saturation * this.$colorpickerArea.width() / 100;
+ this.$colorpickerPointer.css({
+ top: (top - 5) + 'px',
+ left: (left - 5) + 'px',
+ });
+
+ // Update color slider position
+ var height = this.$colorSlider.height();
+ var y = this.colorComponents.hue * height / 360;
+ this.$colorSliderPointer.css('top', Math.round(y - 2));
+
+ // Update opacity slider position
+ var heightOpacity = this.$opacitySlider.height();
+ var z = heightOpacity * (1 - this.colorComponents.opacity / 100.0);
+ this.$opacitySliderPointer.css('top', Math.round(z - 2));
+
+ // Add gradient color on opacity slider
+ this.$opacitySlider.css('background', 'linear-gradient(' + this.colorComponents.hex + ' 0%, transparent 100%)');
+ },
+ /**
+ * Updates colors according to given hex value. Opacity is left unchanged.
+ *
+ * @private
+ * @param {string} hex - hexadecimal code
+ */
+ _updateHex: function (hex) {
+ var rgb = ColorpickerDialog.convertHexToRgba(hex);
+ if (!rgb) {
+ return;
+ }
+ _.extend(this.colorComponents,
+ {hex: hex},
+ rgb,
+ ColorpickerDialog.convertRgbToHsl(rgb.red, rgb.green, rgb.blue)
+ );
+ this._updateCssColor();
+ },
+ /**
+ * Updates colors according to given RGB values.
+ *
+ * @private
+ * @param {integer} r
+ * @param {integer} g
+ * @param {integer} b
+ * @param {integer} [a]
+ */
+ _updateRgba: function (r, g, b, a) {
+ var hex = ColorpickerDialog.convertRgbToHex(r, g, b);
+ if (!hex) {
+ return;
+ }
+ _.extend(this.colorComponents,
+ {red: r, green: g, blue: b},
+ a === undefined ? {} : {opacity: a},
+ hex,
+ ColorpickerDialog.convertRgbToHsl(r, g, b)
+ );
+ this._updateCssColor();
+ },
+ /**
+ * Updates colors according to given HSL values.
+ *
+ * @private
+ * @param {integer} h
+ * @param {integer} s
+ * @param {integer} l
+ */
+ _updateHsl: function (h, s, l) {
+ var rgb = ColorpickerDialog.convertHslToRgb(h, s, l);
+ if (!rgb) {
+ return;
+ }
+ _.extend(this.colorComponents,
+ {hue: h, saturation: s, lightness: l},
+ rgb,
+ ColorpickerDialog.convertRgbToHex(rgb.red, rgb.green, rgb.blue)
+ );
+ this._updateCssColor();
+ },
+ /**
+ * Updates color opacity.
+ *
+ * @private
+ * @param {integer} a
+ */
+ _updateOpacity: function (a) {
+ if (a < 0 || a > 100) {
+ return;
+ }
+ _.extend(this.colorComponents,
+ {opacity: a}
+ );
+ this._updateCssColor();
+ },
+ /**
+ * Updates css color representation.
+ *
+ * @private
+ */
+ _updateCssColor: function () {
+ _.extend(this.colorComponents,
+ {cssColor: ColorpickerDialog.formatColor(this.colorComponents)}
+ );
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates color when the user starts clicking on the picker.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseDownPicker: function (ev) {
+ this.pickerFlag = true;
+ this._onMouseMovePicker(ev);
+ },
+ /**
+ * Updates saturation and lightness values on mouse drag over picker.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseMovePicker: function (ev) {
+ if (!this.pickerFlag) {
+ return;
+ }
+
+ var offset = this.$colorpickerArea.offset();
+ var top = ev.pageY - offset.top;
+ var left = ev.pageX - offset.left;
+ var saturation = Math.round(100 * left / this.$colorpickerArea.width());
+ var lightness = Math.round(100 * (this.$colorpickerArea.height() - top) / this.$colorpickerArea.height());
+ saturation = utils.confine(saturation, 0, 100);
+ lightness = utils.confine(lightness, 0, 100);
+
+ this._updateHsl(this.colorComponents.hue, saturation, lightness);
+ this._updateUI();
+ },
+ /**
+ * Updates color when user starts clicking on slider.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseDownSlider: function (ev) {
+ this.sliderFlag = true;
+ this._onMouseMoveSlider(ev);
+ },
+ /**
+ * Updates hue value on mouse drag over slider.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseMoveSlider: function (ev) {
+ if (!this.sliderFlag) {
+ return;
+ }
+
+ var y = ev.pageY - this.$colorSlider.offset().top;
+ var hue = Math.round(360 * y / this.$colorSlider.height());
+ hue = utils.confine(hue, 0, 360);
+
+ this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness);
+ this._updateUI();
+ },
+ /**
+ * Updates opacity when user starts clicking on opacity slider.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseDownOpacitySlider: function (ev) {
+ this.opacitySliderFlag = true;
+ this._onMouseMoveOpacitySlider(ev);
+ },
+ /**
+ * Updates opacity value on mouse drag over opacity slider.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseMoveOpacitySlider: function (ev) {
+ if (!this.opacitySliderFlag) {
+ return;
+ }
+
+ var y = ev.pageY - this.$opacitySlider.offset().top;
+ var opacity = Math.round(100 * (1 - y / this.$opacitySlider.height()));
+ opacity = utils.confine(opacity, 0, 100);
+
+ this._updateOpacity(opacity);
+ this._updateUI();
+ },
+ /**
+ * Called when input value is changed -> Updates UI: Set picker and slider
+ * position and set colors.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeInputs: function (ev) {
+ switch ($(ev.target).data('colorMethod')) {
+ case 'hex':
+ this._updateHex(this.$('.o_hex_input').val());
+ break;
+ case 'rgb':
+ this._updateRgba(
+ parseInt(this.$('.o_red_input').val()),
+ parseInt(this.$('.o_green_input').val()),
+ parseInt(this.$('.o_blue_input').val())
+ );
+ break;
+ case 'hsl':
+ this._updateHsl(
+ parseInt(this.$('.o_hue_input').val()),
+ parseInt(this.$('.o_saturation_input').val()),
+ parseInt(this.$('.o_lightness_input').val())
+ );
+ break;
+ case 'opacity':
+ this._updateOpacity(parseInt(this.$('.o_opacity_input').val()));
+ break;
+ }
+ this._updateUI();
+ },
+ /**
+ * @private
+ */
+ _onFinalPick: function () {
+ this.trigger_up('colorpicker:saved', this.colorComponents);
+ },
+});
+
+//--------------------------------------------------------------------------
+// Static
+//--------------------------------------------------------------------------
+
+/**
+ * Converts any color to string RGBA.
+ *
+ * @static
+ * @param {Object|string} color
+ * @returns {string} rgba (red, green, blue, opacity)
+ */
+ColorpickerDialog.formatColor = function (color) {
+ if (typeof color === 'string') {
+ color = ColorpickerDialog.convertColorToRgba(color);
+ }
+ if (!color) {
+ return '';
+ }
+ if (color.opacity === 100) {
+ return ColorpickerDialog.convertRgbToHex(color.red, color.green, color.blue).hex;
+ }
+ return _.str.sprintf('rgba(%s, %s, %s, %s)',
+ color.red,
+ color.green,
+ color.blue,
+ color.opacity / 100
+ );
+};
+/**
+ * Converts any color to code to RGBA.
+ *
+ * @static
+ * @param {string} color
+ * @returns {Object|false} contains red, green, blue, opacity
+ */
+ColorpickerDialog.convertColorToRgba = function (color) {
+ var rgba = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
+ if (rgba) {
+ if (rgba[4] === undefined) {
+ rgba[4] = 100;
+ }
+ return {
+ red: parseInt(rgba[1]),
+ green: parseInt(rgba[2]),
+ blue: parseInt(rgba[3]),
+ opacity: parseInt(rgba[4]),
+ };
+ } else {
+ return ColorpickerDialog.convertHexToRgba(color);
+ }
+};
+/**
+ * Converts Hexadecimal code to RGB.
+ *
+ * @static
+ * @param {string} hex - hexadecimal code
+ * @returns {Object|false} contains red, green and blue
+ */
+ColorpickerDialog.convertHexToRgba = function (hex) {
+ if (!/^#([0-9A-F]{6}|[0-9A-F]{8})$/i.test(hex)) {
+ return false;
+ }
+
+ return {
+ red: parseInt(hex.substr(1, 2), 16),
+ green: parseInt(hex.substr(3, 2), 16),
+ blue: parseInt(hex.substr(5, 2), 16),
+ opacity: hex.length === 9 ? parseInt(hex.substr(7, 2), 16) : 100,
+ };
+};
+/**
+ * Converts RGB color to HSL.
+ *
+ * @static
+ * @param {integer} r
+ * @param {integer} g
+ * @param {integer} b
+ * @returns {Object|false} contains hue, saturation and lightness
+ */
+ColorpickerDialog.convertRgbToHsl = function (r, g, b) {
+ if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 ||
+ typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 ||
+ typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) {
+ return false;
+ }
+
+ var red = r / 255;
+ var green = g / 255;
+ var blue = b / 255;
+ var maxColor = Math.max(red, green, blue);
+ var minColor = Math.min(red, green, blue);
+ var delta = maxColor - minColor;
+ var hue = 0;
+ var saturation = 0;
+ var lightness = (maxColor + minColor) / 2;
+ if (delta) {
+ if (maxColor === red) {
+ hue = (green - blue) / delta;
+ }
+ if (maxColor === green) {
+ hue = 2 + (blue - red) / delta;
+ }
+ if (maxColor === blue) {
+ hue = 4 + (red - green) / delta;
+ }
+ if (maxColor) {
+ saturation = delta / (1 - Math.abs(2 * lightness - 1));
+ }
+ }
+ hue = 60 * hue | 0;
+ return {
+ hue: hue < 0 ? hue += 360 : hue,
+ saturation: (saturation * 100) | 0,
+ lightness: (lightness * 100) | 0,
+ };
+};
+/**
+ * Converts HSL color to RGB.
+ *
+ * @static
+ * @param {integer} h
+ * @param {integer} s
+ * @param {integer} l
+ * @returns {Object|false} contains red, green and blue
+ */
+ColorpickerDialog.convertHslToRgb = function (h, s, l) {
+ if (typeof (h) !== 'number' || isNaN(h) || h < 0 || h > 360 ||
+ typeof (s) !== 'number' || isNaN(s) || s < 0 || s > 100 ||
+ typeof (l) !== 'number' || isNaN(l) || l < 0 || l > 100) {
+ return false;
+ }
+ var huePrime = h / 60;
+ var saturation = s / 100;
+ var lightness = l / 100;
+ var chroma = saturation * (1 - Math.abs(2 * lightness - 1));
+ var secondComponent = chroma * (1 - Math.abs(huePrime % 2 - 1));
+ var lightnessAdjustment = lightness - chroma / 2;
+ var precision = 255;
+ chroma = (chroma + lightnessAdjustment) * precision | 0;
+ secondComponent = (secondComponent + lightnessAdjustment) * precision | 0;
+ lightnessAdjustment = lightnessAdjustment * precision | 0;
+ if (huePrime >= 0 && huePrime < 1) {
+ return {
+ red: chroma,
+ green: secondComponent,
+ blue: lightnessAdjustment,
+ };
+ }
+ if (huePrime >= 1 && huePrime < 2) {
+ return {
+ red: secondComponent,
+ green: chroma,
+ blue: lightnessAdjustment,
+ };
+ }
+ if (huePrime >= 2 && huePrime < 3) {
+ return {
+ red: lightnessAdjustment,
+ green: chroma,
+ blue: secondComponent,
+ };
+ }
+ if (huePrime >= 3 && huePrime < 4) {
+ return {
+ red: lightnessAdjustment,
+ green: secondComponent,
+ blue: chroma,
+ };
+ }
+ if (huePrime >= 4 && huePrime < 5) {
+ return {
+ red: secondComponent,
+ green: lightnessAdjustment,
+ blue: chroma,
+ };
+ }
+ if (huePrime >= 5 && huePrime <= 6) {
+ return {
+ red: chroma,
+ green: lightnessAdjustment,
+ blue: secondComponent,
+ };
+ }
+};
+/**
+ * Converts RGB color to Hexadecimal code.
+ *
+ * @static
+ * @param {integer} r
+ * @param {integer} g
+ * @param {integer} b
+ * @returns {Object|false} contains hexadecimal code
+ */
+ColorpickerDialog.convertRgbToHex = function (r, g, b) {
+ if (typeof (r) !== 'number' || isNaN(r) || r < 0 || r > 255 ||
+ typeof (g) !== 'number' || isNaN(g) || g < 0 || g > 255 ||
+ typeof (b) !== 'number' || isNaN(b) || b < 0 || b > 255) {
+ return false;
+ }
+ var red = r < 16 ? '0' + r.toString(16) : r.toString(16);
+ var green = g < 16 ? '0' + g.toString(16) : g.toString(16);
+ var blue = b < 16 ? '0' + b.toString(16) : b.toString(16);
+ return {
+ hex: _.str.sprintf('#%s%s%s', red, green, blue)
+ };
+};
+
+return ColorpickerDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/crop_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/crop_dialog.js
new file mode 100644
index 00000000000..77bcd5578b3
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/crop_dialog.js
@@ -0,0 +1,218 @@
+odoo.define('wysiwyg.widgets.CropImageDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var _t = core._t;
+
+/**
+ * CropImageDialog widget. Let users crop an image.
+ */
+var CropImageDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.crop_image',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/editor.xml']
+ ),
+ jsLibs: [
+ '/web_editor/static/lib/cropper/js/cropper.js',
+ ],
+ cssLibs: [
+ '/web_editor/static/lib/cropper/css/cropper.css',
+ ],
+ events: _.extend({}, Dialog.prototype.events, {
+ 'click .o_crop_options [data-event]': '_onCropOptionClick',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, media) {
+ var self = this;
+ this.media = media;
+ this.$media = $(this.media);
+ var src = this.$media.attr('src').split('?')[0];
+ this.aspectRatioList = [
+ [_t("Free"), '0/0', 0],
+ ["16:9", '16/9', 16 / 9],
+ ["4:3", '4/3', 4 / 3],
+ ["1:1", '1/1', 1],
+ ["2:3", '2/3', 2 / 3],
+ ];
+ this.imageData = {
+ imageSrc: src,
+ originalSrc: this.$media.data('crop:originalSrc') || src, // the original src for cropped DB images will be fetched later
+ mimetype: this.$media.data('crop:mimetype') || (_.str.endsWith(src, '.png') ? 'image/png' : 'image/jpeg'), // the mimetype for DB images will be fetched later
+ aspectRatio: this.$media.data('aspectRatio') || this.aspectRatioList[0][1],
+ isExternalImage: src.substr(0, 5) !== 'data:' && src[0] !== '/' && src.indexOf(window.location.host) < 0,
+ };
+ this.options = _.extend({
+ title: _t("Crop Image"),
+ buttons: this.imageData.isExternalImage ? [{
+ text: _t("Close"),
+ close: true,
+ }] : [{
+ text: _t("Save"),
+ classes: 'btn-primary',
+ click: this.save,
+ }, {
+ text: _t("Discard"),
+ close: true,
+ }],
+ }, options || {});
+ this._super(parent, this.options);
+ this.trigger_up('getRecordInfo', _.extend(this.options, {
+ callback: function (recordInfo) {
+ _.defaults(self.options, recordInfo);
+ },
+ }));
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var def = this._super.apply(this, arguments);
+ if (this.imageData.isExternalImage) {
+ return def;
+ }
+
+ var defs = [def];
+ var params = {};
+ var isDBImage = false;
+ var matchImageID = this.imageData.imageSrc.match(/^\/web\/image\/(\d+)/);
+ if (matchImageID) {
+ params.image_id = parseInt(matchImageID[1]);
+ isDBImage = true;
+ } else {
+ var matchXmlID = this.imageData.imageSrc.match(/^\/web\/image\/([^/?]+)/);
+ if (matchXmlID) {
+ params.xml_id = matchXmlID[1];
+ isDBImage = true;
+ }
+ }
+ if (isDBImage) {
+ defs.push(this._rpc({
+ route: '/web_editor/get_image_info',
+ params: params,
+ }).then(function (res) {
+ _.extend(self.imageData, res);
+ }));
+ }
+ return $.when.apply($, defs);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$cropperImage = this.$('.o_cropper_image');
+ if (this.$cropperImage.length) {
+ var data = this.$media.data();
+ var ratio = 0;
+ for (var i = 0; i < this.aspectRatioList.length; i++) {
+ if (this.aspectRatioList[i][1] === data.aspectRatio) {
+ ratio = this.aspectRatioList[i][2];
+ break;
+ }
+ }
+ this.$cropperImage.cropper({
+ viewMode: 1,
+ autoCropArea: 1,
+ aspectRatio: ratio,
+ data: _.pick(data, 'x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY')
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.$cropperImage.length) {
+ this.$cropperImage.cropper('destroy');
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Updates the DOM image with cropped data and associates required
+ * information for a potential future save (where required cropped data
+ * attachments will be created).
+ *
+ * @override
+ */
+ save: function () {
+ var self = this;
+ var cropperData = this.$cropperImage.cropper('getData');
+
+ // Mark the media for later creation of required cropped attachments...
+ this.$media.addClass('o_cropped_img_to_save');
+
+ // ... and attach required data
+ this.$media.data('crop:resModel', this.options.res_model);
+ this.$media.data('crop:resID', this.options.res_id);
+ this.$media.data('crop:id', this.imageData.id);
+ this.$media.data('crop:mimetype', this.imageData.mimetype);
+ this.$media.data('crop:originalSrc', this.imageData.originalSrc);
+
+ // Mark the media with the cropping information which is required for
+ // a future crop edition
+ this.$media
+ .attr('data-aspect-ratio', this.imageData.aspectRatio)
+ .data('aspectRatio', this.imageData.aspectRatio);
+ _.each(cropperData, function (value, key) {
+ key = _.str.dasherize(key);
+ self.$media.attr('data-' + key, value);
+ self.$media.data(key, value);
+ });
+
+ // Update the media with base64 source for preview before saving
+ var canvas = this.$cropperImage.cropper('getCroppedCanvas', {
+ width: cropperData.width,
+ height: cropperData.height,
+ });
+ this.$media.attr('src', canvas.toDataURL(this.imageData.mimetype));
+
+ this.trigger('saved', {
+ media: this.$media[0],
+ });
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a crop option is clicked -> change the crop area accordingly.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCropOptionClick: function (ev) {
+ var $option = $(ev.currentTarget);
+ var opt = $option.data('event');
+ var value = $option.data('value');
+ switch (opt) {
+ case 'ratio':
+ this.$cropperImage.cropper('reset');
+ this.imageData.aspectRatio = $option.data('label');
+ this.$cropperImage.cropper('setAspectRatio', value);
+ break;
+ case 'zoom':
+ case 'rotate':
+ case 'reset':
+ this.$cropperImage.cropper(opt, value);
+ break;
+ case 'flip':
+ var direction = value === 'horizontal' ? 'x' : 'y';
+ var scaleAngle = -$option.data(direction);
+ $option.data(direction, scaleAngle);
+ this.$cropperImage.cropper('scale' + direction.toUpperCase(), scaleAngle);
+ break;
+ }
+ },
+});
+
+
+return CropImageDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js
new file mode 100644
index 00000000000..b46e43ab051
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js
@@ -0,0 +1,58 @@
+odoo.define('wysiwyg.widgets.Dialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+
+var _t = core._t;
+
+/**
+ * Extend Dialog class to handle save/cancel of edition components.
+ */
+var SummernoteDialog = Dialog.extend({
+ /**
+ * @constructor
+ */
+ init: function (parent, options) {
+ this.options = options || {};
+ this._super(parent, _.extend({}, {
+ buttons: [{
+ text: this.options.save_text || _t("Save"),
+ classes: 'btn-primary',
+ click: this.save,
+ },
+ {
+ text: _t("Discard"),
+ close: true,
+ }
+ ]
+ }, this.options));
+
+ this.destroyAction = 'cancel';
+
+ var self = this;
+ this.opened(function () {
+ self.$('input:first').focus();
+ self.$el.closest('.modal').on('hidden.bs.modal', self.options.onClose);
+ });
+ this.on('closed', this, function () {
+ this.trigger(this.destroyAction, this.final_data || null);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the dialog is saved. Set the destroy action type to "save"
+ * and should set the final_data variable correctly before closing.
+ */
+ save: function () {
+ this.destroyAction = "save";
+ this.close();
+ },
+});
+
+return SummernoteDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
new file mode 100644
index 00000000000..b0ef36bd584
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
@@ -0,0 +1,219 @@
+odoo.define('wysiwyg.widgets.LinkDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var _t = core._t;
+
+/**
+ * Allows to customize link content and style.
+ */
+var LinkDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.link',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/editor.xml']
+ ),
+ events: _.extend({}, Dialog.prototype.events || {}, {
+ 'input': '_onAnyChange',
+ 'change': '_onAnyChange',
+ 'input input[name="url"]': '_onURLInput',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, linkInfo) {
+ var self = this;
+ this.options = options || {};
+
+ this._super(parent, _.extend({
+ title: _t("Link to"),
+ }, this.options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: this.options,
+ callback: function (recordInfo) {
+ _.defaults(self.options, recordInfo);
+ },
+ });
+
+ this.data = linkInfo || {};
+ this.needLabel = linkInfo.needLabel;
+ this.data.iniClassName = linkInfo.className;
+ var allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
+ var allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
+ this.data.className = linkInfo.className
+ .replace(allBtnClassSuffixes, ' ')
+ .replace(allBtnShapes, ' ');
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ this.$('input.link-style').prop('checked', false).first().prop('checked', true);
+ if (this.data.iniClassName) {
+ this.$('input[name="link_style_color"], select[name="link_style_size"] > option, select[name="link_style_shape"] > option').each(function () {
+ var $option = $(this);
+ if ($option.val() && self.data.iniClassName.match(new RegExp('(^|btn-| |btn-outline-)' + $option.val()))) {
+ if ($option.is("input")) {
+ $option.prop("checked", true);
+ } else {
+ $option.parent().find('option').removeAttr('selected').removeProp('selected');
+ $option.parent().val($option.val());
+ $option.attr('selected', 'selected').prop('selected', 'selected');
+ }
+ }
+ });
+ }
+ if (this.data.url) {
+ var match = /mailto:(.+)/.exec(this.data.url);
+ this.$('input[name="url"]').val(match ? match[1] : this.data.url);
+ }
+
+ // Hide the duplicate color buttons (most of the times, primary = alpha
+ // and secondary = beta for example but this may depend on the theme)
+ this.opened().then(function () {
+ var colors = [];
+ _.each(self.$('.o_link_dialog_color .o_btn_preview'), function (btn) {
+ var $btn = $(btn);
+ var color = $btn.css('background-color');
+ if (_.contains(colors, color)) {
+ $btn.hide(); // Not remove to be able to edit buttons with those styles
+ } else {
+ colors.push(color);
+ }
+ });
+ });
+
+ this._adaptPreview();
+
+ this.$('input:visible:first').focus();
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var data = this._getData();
+ if (data === null) {
+ var $url = this.$('input[name="url"]');
+ $url.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ $url.focus();
+ return $.Deferred().reject();
+ }
+ this.data.text = data.label;
+ this.data.url = data.url;
+ var allWhitespace = /\s+/gi;
+ var allStartAndEndSpace = /^\s+|\s+$/gi;
+ var allBtnTypes = /(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi;
+ this.data.className = data.classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, '');
+ if (data.classes.replace(allBtnTypes, ' ')) {
+ this.data.style = {
+ 'background-color': '',
+ 'color': '',
+ };
+ }
+ this.data.isNewWindow = data.isNewWindow;
+ this.final_data = this.data;
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adapt the link preview to changes.
+ *
+ * @private
+ */
+ _adaptPreview: function () {
+ var $preview = this.$("#link-preview");
+ var data = this._getData();
+ if (data === null) {
+ return;
+ }
+ var floatClass = /float-\w+/;
+ $preview.attr({
+ target: data.isNewWindow ? '_blank' : '',
+ href: data.url && data.url.length ? data.url : '#',
+ class: data.classes.replace(floatClass, '') + ' o_btn_preview',
+ }).html((data.label && data.label.length) ? data.label : data.url);
+ },
+ /**
+ * Get the link's data (url, label and styles).
+ *
+ * @private
+ * @returns {Object} {label: String, url: String, classes: String, isNewWindow: Boolean}
+ */
+ _getData: function () {
+ var $url = this.$('input[name="url"]');
+ var url = $url.val();
+ var label = this.$('input[name="label"]').val() || url;
+
+ if (label && this.data.images) {
+ for (var i = 0; i < this.data.images.length; i++) {
+ label = label.replace('<', "&lt;").replace('>', "&gt;").replace(/\[IMG\]/, this.data.images[i].outerHTML);
+ }
+ }
+
+ if ($url.prop('required') && (!url || !$url[0].checkValidity())) {
+ return null;
+ }
+
+ var style = this.$('input[name="link_style_color"]:checked').val() || '';
+ var shape = this.$('select[name="link_style_shape"] option:selected').val() || '';
+ var size = this.$('select[name="link_style_size"] option:selected').val() || '';
+ var shapes = shape.split(',');
+ var outline = shapes[0] === 'outline';
+ shape = shapes.slice(outline ? 1 : 0).join(' ');
+ var classes = (this.data.className || '') +
+ (style ? (' btn btn-' + (outline ? 'outline-' : '') + style) : '') +
+ (shape ? (' ' + shape) : '') +
+ (size ? (' btn-' + size) : '');
+ var isNewWindow = this.$('input[name="is_new_window"]').prop('checked');
+
+ if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) {
+ url = ('mailto:' + url);
+ }
+ var allWhitespace = /\s+/gi;
+ var allStartAndEndSpace = /^\s+|\s+$/gi;
+ return {
+ label: label,
+ url: url,
+ classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''),
+ isNewWindow: isNewWindow,
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAnyChange: function () {
+ this._adaptPreview();
+ },
+ /**
+ * @private
+ */
+ _onURLInput: function (ev) {
+ $(ev.currentTarget).closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ var isLink = $(ev.currentTarget).val().indexOf('@') < 0;
+ this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink);
+ },
+});
+
+return LinkDialog;
+});
diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
index 9dad13a0b2b..0b113430352 100644
--- a/addons/web_editor/static/src/js/widgets/widgets.js
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
@@ -1,112 +1,16 @@
-odoo.define('web_editor.widget', function (require) {
+odoo.define('wysiwyg.widgets.media', function (require) {
'use strict';
-var ajax = require('web.ajax');
-var base = require('web_editor.base');
var core = require('web.core');
-var DialogBase = require('web.Dialog');
+var Dialog = require('web.Dialog');
+var fonts = require('wysiwyg.fonts');
var Widget = require('web.Widget');
-var weContext = require("web_editor.context");
+var concurrency = require('web.concurrency');
var QWeb = core.qweb;
-var range = $.summernote.core.range;
-var dom = $.summernote.core.dom;
var _t = core._t;
-/**
- * @todo we should either get rid of this or move it somewhere else
- */
-function simulateMouseEvent(el, type) {
- var evt = document.createEvent("MouseEvents");
- evt.initMouseEvent(type, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, el);
- el.dispatchEvent(evt);
-}
-
-/**
- * Extend Dialog class to handle save/cancel of edition components.
- */
-var Dialog = DialogBase.extend({
- /**
- * @constructor
- */
- init: function (parent, options) {
- options = options || {};
- this._super(parent, _.extend({}, {
- buttons: [
- {text: options.save_text || _t("Save"), classes: 'btn-primary', click: this.save},
- {text: _t("Discard"), close: true}
- ]
- }, options));
-
- this.destroyAction = 'cancel';
-
- var self = this;
- this.opened().then(function () {
- self.$('input:first').focus();
- });
- this.on('closed', this, function () {
- this.trigger(this.destroyAction, this.final_data || null);
- });
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Called when the dialog is saved. Set the destroy action type to "save"
- * and should set the final_data variable correctly before closing.
- */
- save: function () {
- this.destroyAction = "save";
- this.close();
- },
-});
-
-/**
- * Let users change the alt & title of a media.
- */
-var AltDialog = Dialog.extend({
- template: 'web_editor.dialog.alt',
- xmlDependencies: Dialog.prototype.xmlDependencies.concat(
- ['/web_editor/static/src/xml/editor.xml']
- ),
-
- /**
- * @constructor
- */
- init: function (parent, options, $editable, media) {
- this._super(parent, _.extend({}, {
- title: _t("Change media description and tooltip")
- }, options));
- this.$editable = $editable;
- this.media = media;
- this.alt = ($(this.media).attr('alt') || "").replace(/&quot;/g, '"');
- this.tag_title = ($(this.media).attr('title') || "").replace(/&quot;/g, '"');
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- save: function () {
- var self = this;
- range.createFromNode(this.media).select();
- this.$editable.data('NoteHistory').recordUndo();
- var alt = this.$('#alt').val();
- var title = this.$('#title').val();
- $(this.media).attr('alt', alt ? alt.replace(/"/g, "&quot;") : null).attr('title', title ? title.replace(/"/g, "&quot;") : null);
- _.defer(function () {
- simulateMouseEvent(self.media, 'mouseup');
- });
- return this._super.apply(this, arguments);
- },
-});
-
var MediaWidget = Widget.extend({
xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
events: {
@@ -156,14 +60,6 @@ var MediaWidget = Widget.extend({
* @abstract
*/
_clear: function () {},
- /**
- * @private
- */
- _replaceMedia: function ($media) {
- this.$media.replaceWith($media);
- this.$media = $media;
- this.media = $media[0];
- },
//--------------------------------------------------------------------------
// Handlers
@@ -182,13 +78,13 @@ var MediaWidget = Widget.extend({
* Let users choose an image, including uploading a new image in odoo.
*/
var ImageWidget = MediaWidget.extend({
- template: 'web_editor.dialog.image',
+ template: 'wysiwyg.widgets.image',
events: _.extend({}, MediaWidget.prototype.events || {}, {
'click .o_upload_media_button': '_onUploadButtonClick',
'click .o_upload_media_button_no_optimization': '_onUploadButtonNoOptimizationClick',
'change input[type=file]': '_onImageSelection',
'click .o_upload_media_url_button': '_onUploadURLButtonClick',
- 'input input.o_we_url_input': '_onURLInputChange',
+ 'input input[name="url"]': '_onURLInputChange',
'click .existing-attachments [data-src]': '_onImageClick',
'dblclick .existing-attachments [data-src]': '_onImageDblClick',
'click .o_existing_attachment_remove': '_onRemoveClick',
@@ -203,21 +99,14 @@ var ImageWidget = MediaWidget.extend({
*/
init: function (parent, media, options) {
this._super.apply(this, arguments);
+ this._mutex = new concurrency.Mutex();
this.imagesRows = this.IMAGES_ROWS;
this.IMAGES_DISPLAYED_TOTAL = this.IMAGES_PER_ROW * this.imagesRows;
this.options = options;
+ this.context = options.context;
this.accept = options.accept || (options.document ? '*/*' : 'image/*');
- if (options.domain) {
- this.domain = typeof options.domain === 'function' ? options.domain() : options.domain;
- } else if (options.res_id) {
- this.domain = ['|',
- '&', ['res_model', '=', options.res_model], ['res_id', '=', options.res_id],
- ['res_model', '=', 'ir.ui.view']];
- } else {
- this.domain = [['res_model', '=', 'ir.ui.view']];
- }
this.multiImages = options.multiImages;
@@ -269,75 +158,7 @@ var ImageWidget = MediaWidget.extend({
* @override
*/
save: function () {
- var self = this;
- if (this.multiImages) {
- return this.images;
- }
-
- var img = this.images[0];
- if (!img) {
- return this.media;
- }
-
- var def = $.when();
- if (!img.access_token) {
- def = this._rpc({
- model: 'ir.attachment',
- method: 'generate_access_token',
- args: [[img.id]]
- }).then(function (access_token) {
- img.access_token = access_token[0];
- });
- }
-
- return def.then(function () {
- if (!img.isDocument) {
- if (img.access_token && self.options.res_model !== 'ir.ui.view') {
- img.src += _.str.sprintf('?access_token=%s', img.access_token);
- }
- if (!self.$media.is('img')) {
- // Note: by default the images receive the bootstrap opt-in
- // img-fluid class. We cannot make them all responsive
- // by design because of libraries and client databases img.
- self._replaceMedia($('<img/>', {class: 'img-fluid o_we_custom_image'}));
- }
- self.$media.attr('src', img.src);
-
- } else {
- if (!self.$media.is('a')) {
- $('.note-control-selection').hide();
- self._replaceMedia($('<a/>'));
- }
- var href = '/web/content/' + img.id + '?';
- if (img.access_token && self.options.res_model !== 'ir.ui.view') {
- href += _.str.sprintf('access_token=%s&', img.access_token);
- }
- href += 'unique=' + img.checksum + '&download=true';
- self.$media.attr('href', href);
- self.$media.addClass('o_image').attr('title', img.name).attr('data-mimetype', img.mimetype);
- }
-
- self.$media.attr('alt', img.alt);
- var style = self.style;
- if (style) {
- self.$media.css(style);
- }
-
- if (self.options.onUpload) {
- // We consider that when selecting an image it is as if we upload it in the html content.
- self.options.onUpload([img]);
- }
-
- // Remove crop related attributes
- if (self.$media.attr('data-aspect-ratio')) {
- var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y'];
- _.each(attrs, function (attr) {
- self.$media.removeData(attr);
- self.$media.removeAttr('data-' + attr);
- });
- }
- return self.media;
- });
+ return this._mutex.exec(this._save.bind(this));
},
/**
* @override
@@ -347,21 +168,15 @@ var ImageWidget = MediaWidget.extend({
if (!noRender) {
this.$('input.o_we_url_input').val('').trigger('input').trigger('change');
}
- // TODO: Expand this for adding SVG
- var domain = this.domain.concat(['|', ['mimetype', '=', false], ['mimetype', this.options.document ? 'not in' : 'in', ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png']]]);
- if (needle && needle.length) {
- domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]);
- }
- domain.push('|', ['datas_fname', '=', false], '!', ['datas_fname', '=like', '%.crop'], '!', ['name', '=like', '%.crop']);
return this._rpc({
model: 'ir.attachment',
method: 'search_read',
args: [],
kwargs: {
- domain: domain,
+ domain: this._getAttachmentsDomain(needle),
fields: ['name', 'datas_fname', 'mimetype', 'checksum', 'url', 'type', 'res_id', 'res_model', 'access_token'],
order: [{name: 'id', asc: false}],
- context: weContext.get(),
+ context: this.context,
},
}).then(function (records) {
self.records = _.chain(records)
@@ -415,7 +230,62 @@ var ImageWidget = MediaWidget.extend({
* @override
*/
_clear: function () {
- this.media.className = this.media.className.replace(/(^|\s+)((img(\s|$)|img-(?!circle|rounded|thumbnail))[^\s]*)/g, ' ');
+ if (!this.$media.is('img')) {
+ return;
+ }
+ var allImgClasses = /(^|\s+)((img(\s|$)|img-(?!circle|rounded|thumbnail))[^\s]*)/g;
+ var allImgClassModifiers = /(^|\s+)(rounded-circle|shadow|rounded|img-thumbnail|mx-auto)([^\s]*)/g;
+ this.media.className = this.media.className && this.media.className
+ .replace('o_we_custom_image', '')
+ .replace(allImgClasses, ' ')
+ .replace(allImgClassModifiers, ' ');
+ },
+ /**
+ * Returns the domain for attachments used in media dialog.
+ * We look for attachments related to the current document. If there is a value for the model
+ * field, it is used to search attachments, and the attachments from the current document are
+ * filtered to display only user-created documents.
+ * In the case of a wizard such as mail, we have the documents uploaded and those of the model
+ *
+ * @private
+ * @params {string} needle
+ * @returns {Array} "ir.attachment" odoo domain.
+ */
+ _getAttachmentsDomain: function (needle) {
+ var domain = this.options.attachmentIDs && this.options.attachmentIDs.length ? ['|', ['id', 'in', this.options.attachmentIDs]] : [];
+
+ var attachedDocumentDomain = [
+ '&',
+ ['res_model', '=', this.options.res_model],
+ ['res_id', '=', this.options.res_id|0]
+ ];
+ // if the document is not yet created, do not see the documents of other users
+ if (!this.options.res_id) {
+ attachedDocumentDomain.unshift('&');
+ attachedDocumentDomain.push(['create_uid', '=', this.options.user_id]);
+ }
+ if (this.options.data_res_model) {
+ var relatedDomain = ['&',
+ ['res_model', '=', this.options.data_res_model],
+ ['res_id', '=', this.options.data_res_id|0]];
+ if (!this.options.data_res_id) {
+ relatedDomain.unshift('&');
+ relatedDomain.push(['create_uid', '=', session.uid]);
+ }
+ domain = domain.concat(['|'], attachedDocumentDomain, relatedDomain);
+ } else {
+ domain = domain.concat(attachedDocumentDomain);
+ }
+ domain = ['|', ['public', '=', true]].concat(domain);
+
+ domain.push('|',
+ ['mimetype', '=', false],
+ ['mimetype', this.options.document ? 'not in' : 'in', ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png']]);
+ if (needle && needle.length) {
+ domain.push('|', ['datas_fname', 'ilike', needle], ['name', 'ilike', needle]);
+ }
+ domain.push('|', ['datas_fname', '=', false], '!', ['datas_fname', '=like', '%.crop'], '!', ['name', '=like', '%.crop']);
+ return domain;
},
/**
* @private
@@ -458,9 +328,9 @@ var ImageWidget = MediaWidget.extend({
this.$('.form-text').empty();
- // Render menu & content
+ // Render menu & content
this.$('.existing-attachments').replaceWith(
- QWeb.render('web_editor.dialog.files.existing.content', {
+ QWeb.render('wysiwyg.widgets.files.existing.content', {
rows: rows,
isDocument: this.options.document,
withEffect: withEffect,
@@ -493,12 +363,88 @@ var ImageWidget = MediaWidget.extend({
/**
* @private
*/
+ _save: function () {
+ var self = this;
+ if (this.multiImages) {
+ return this.images;
+ }
+
+ var img = this.images[0];
+ if (!img) {
+ return this.media;
+ }
+
+ var def = $.when();
+ if (!img.access_token) {
+ def = this._rpc({
+ model: 'ir.attachment',
+ method: 'generate_access_token',
+ args: [[img.id]]
+ }).then(function (access_token) {
+ img.access_token = access_token[0];
+ });
+ }
+
+ return def.then(function () {
+ if (!img.isDocument) {
+ if (img.access_token && self.options.res_model !== 'ir.ui.view') {
+ img.src += _.str.sprintf('?access_token=%s', img.access_token);
+ }
+ if (!self.$media.is('img')) {
+ // Note: by default the images receive the bootstrap opt-in
+ // img-fluid class. We cannot make them all responsive
+ // by design because of libraries and client databases img.
+ self.$media = $('<img/>', {class: 'img-fluid o_we_custom_image'});
+ self.media = self.$media[0];
+ }
+ self.$media.attr('src', img.src);
+
+ } else {
+ if (!self.$media.is('a')) {
+ $('.note-control-selection').hide();
+ self.$media = $('<a/>');
+ self.media = self.$media[0];
+ }
+ var href = '/web/content/' + img.id + '?';
+ if (img.access_token && self.options.res_model !== 'ir.ui.view') {
+ href += _.str.sprintf('access_token=%s&', img.access_token);
+ }
+ href += 'unique=' + img.checksum + '&download=true';
+ self.$media.attr('href', href);
+ self.$media.addClass('o_image').attr('title', img.name).attr('data-mimetype', img.mimetype);
+ }
+
+ self.$media.attr('alt', img.alt);
+ var style = self.style;
+ if (style) {
+ self.$media.css(style);
+ }
+
+ if (self.options.onUpload) {
+ // We consider that when selecting an image it is as if we upload it in the html content.
+ self.options.onUpload([img]);
+ }
+
+ // Remove crop related attributes
+ if (self.$media.attr('data-aspect-ratio')) {
+ var attrs = ['aspect-ratio', 'x', 'y', 'width', 'height', 'rotate', 'scale-x', 'scale-y'];
+ _.each(attrs, function (attr) {
+ self.$media.removeData(attr);
+ self.$media.removeAttr('data-' + attr);
+ });
+ }
+ return self.media;
+ });
+ },
+ /**
+ * @private
+ */
_toggleImage: function (attachment, clearSearch, forceSelect) {
if (this.multiImages) {
var img = _.select(this.images, function (v) { return v.id === attachment.id; });
if (img.length) {
if (!forceSelect) {
- this.images.splice(this.images.indexOf(img[0]), 1);
+ this.images.splice(this.images.indexOf(img[0]),1);
}
} else {
this.images.push(attachment);
@@ -516,16 +462,20 @@ var ImageWidget = MediaWidget.extend({
* @private
*/
_uploadFile: function () {
+ return this._mutex.exec(this._uploadImageIframe.bind(this));
+ },
+ _uploadImageIframe: function () {
var self = this;
-
+ var def = $.Deferred();
/**
* @todo file upload cannot be handled with _rpc smoothly. This uses the
* form posting in iframe trick to handle the upload.
*/
- var $iframe = this.$('.o_file_upload_iframe');
+ var $iframe = this.$('iframe');
$iframe.on('load', function () {
var iWindow = $iframe[0].contentWindow;
- var attachments = iWindow.attachments;
+
+ var attachments = iWindow.attachments || [];
var error = iWindow.error;
self.$('.well > span').remove();
@@ -538,7 +488,7 @@ var ImageWidget = MediaWidget.extend({
_processFile(null, error || !attachments.length);
}
self.images = attachments;
- for (var i = 0; i < attachments.length; i++) {
+ for (var i = 0 ; i < attachments.length ; i++) {
_processFile(attachments[i], error);
}
@@ -546,21 +496,32 @@ var ImageWidget = MediaWidget.extend({
self.options.onUpload(attachments);
}
+ def.resolve();
+
function _processFile(attachment, error) {
+ var $button = self.$('.o_upload_image_button');
if (!error) {
+ $button.addClass('btn-success');
self._toggleImage(attachment, true);
} else {
+ $button.addClass('btn-danger');
self.$el.addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
self.$el.find('.form-text').text(error);
}
+
+ if (!self.multiImages) {
+ self.trigger_up('save_request');
+ }
}
});
this.$el.submit();
- // Empty file input
this.$('.o_file_input').val('');
+
+ return def;
},
+
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
@@ -568,12 +529,12 @@ var ImageWidget = MediaWidget.extend({
/**
* @private
*/
- _onImageClick: function (ev, forceSelect) {
+ _onImageClick: function (ev, force_select) {
var $img = $(ev.currentTarget);
var attachment = _.find(this.records, function (record) {
return record.id === $img.data('id');
});
- this._toggleImage(attachment, false, forceSelect);
+ this._toggleImage(attachment, false, force_select);
},
/**
* @private
@@ -598,14 +559,13 @@ var ImageWidget = MediaWidget.extend({
*/
_onRemoveClick: function (ev) {
var self = this;
- DialogBase.confirm(this, _t("Are you sure you want to delete this file ?"), {
+ Dialog.confirm(this, _t("Are you sure you want to delete this file ?"), {
confirm_callback: function () {
var $helpBlock = this.$('.form-text').empty();
var $a = $(ev.currentTarget);
var id = parseInt($a.data('id'), 10);
var attachment = _.findWhere(this.records, {id: id});
-
- return self._rpc({
+ return self._rpc({
route: '/web_editor/attachment/remove',
params: {
ids: [id],
@@ -613,10 +573,10 @@ var ImageWidget = MediaWidget.extend({
}).then(function (prevented) {
if (_.isEmpty(prevented)) {
self.records = _.without(self.records, attachment);
- self.search('');
+ self._renderImages();
return;
}
- $helpBlock.replaceWith(QWeb.render('web_editor.dialog.image.existing.error', {
+ $helpBlock.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', {
views: prevented[id],
}));
});
@@ -690,7 +650,7 @@ var ImageWidget = MediaWidget.extend({
* css files.
*/
var IconWidget = MediaWidget.extend({
- template: 'web_editor.dialog.font-icons',
+ template: 'wysiwyg.widgets.font-icons',
events: _.extend({}, MediaWidget.prototype.events || {}, {
'click .font-icons-icon': '_onIconClick',
'dblclick .font-icons-icon': '_onIconDblClick',
@@ -702,8 +662,8 @@ var IconWidget = MediaWidget.extend({
init: function (parent, media) {
this._super.apply(this, arguments);
- base.computeFonts();
- this.iconsParser = base.fontIcons;
+ fonts.computeFonts();
+ this.iconsParser = fonts.fontIcons;
this.alias = _.flatten(_.map(this.iconsParser, function (data) {
return data.alias;
}));
@@ -721,7 +681,7 @@ var IconWidget = MediaWidget.extend({
this._highlightSelectedIcon();
}
}
- this.nonIconClasses = _.without(classes, this.selectedIcon);
+ this.nonIconClasses = _.without(classes, 'media_iframe_video', this.selectedIcon);
return this._super.apply(this, arguments);
},
@@ -740,12 +700,13 @@ var IconWidget = MediaWidget.extend({
if (!this.$media.is('span')) {
var $span = $('<span/>');
$span.data(this.$media.data());
- this._replaceMedia($span);
+ this.$media = $span;
+ this.media = this.$media[0];
style = style.replace(/\s*width:[^;]+/, '');
}
this.$media.attr({
class: _.compact(finalClasses).join(' '),
- style: style,
+ style: style || null,
});
return this.media;
},
@@ -771,7 +732,7 @@ var IconWidget = MediaWidget.extend({
});
}
this.$('div.font-icons-icons').html(
- QWeb.render('web_editor.dialog.font-icons.icons', {iconsParser: iconsParser})
+ QWeb.render('wysiwyg.widgets.font-icons.icons', {iconsParser: iconsParser})
);
},
@@ -783,7 +744,8 @@ var IconWidget = MediaWidget.extend({
* @override
*/
_clear: function () {
- this.media.className = this.media.className.replace(/(^|\s)(fa(\s|$)|fa-[^\s]*)/g, ' ');
+ var allFaClasses = /(^|\s)(fa(\s|$)|fa-[^\s]*)/g;
+ this.media.className = this.media.className && this.media.className.replace(allFaClasses, ' ');
},
/**
* @private
@@ -845,7 +807,7 @@ var IconWidget = MediaWidget.extend({
* Let users choose a video, support all summernote video, and embed iframe.
*/
var VideoWidget = MediaWidget.extend({
- template: 'web_editor.dialog.video',
+ template: 'wysiwyg.widgets.video',
events: _.extend({}, MediaWidget.prototype.events || {}, {
'change .o_video_dialog_options input': '_onUpdateVideoOption',
'input textarea#o_video_text': '_onVideoCodeInput',
@@ -865,8 +827,8 @@ var VideoWidget = MediaWidget.extend({
start: function () {
this.$content = this.$('.o_video_dialog_iframe');
- var $media = $(this.media);
- if ($media.hasClass('media_iframe_video')) {
+ if (this.media) {
+ var $media = $(this.media);
var src = $media.data('oe-expression') || $media.data('src') || '';
this.$('textarea#o_video_text').val(src);
@@ -894,13 +856,14 @@ var VideoWidget = MediaWidget.extend({
save: function () {
this._updateVideo();
if (this.$('.o_video_dialog_iframe').is('iframe')) {
- this._replaceMedia($(
+ this.$media = $(
'<div class="media_iframe_video" data-oe-expression="' + this.$content.attr('src') + '">' +
'<div class="css_editable_mode_display">&nbsp;</div>' +
'<div class="media_iframe_video_size" contenteditable="false">&nbsp;</div>' +
'<iframe src="' + this.$content.attr('src') + '" frameborder="0" contenteditable="false"></iframe>' +
'</div>'
- ));
+ );
+ this.media = this.$media[0];
}
return this.media;
},
@@ -920,7 +883,9 @@ var VideoWidget = MediaWidget.extend({
this.media.dataset.src = undefined;
}
}
- this.media.className = this.media.className.replace(/(^|\s)media_iframe_video(\s|$)/g, ' ');
+ var allVideoClasses = /(^|\s)media_iframe_video(\s|$)/g;
+ this.media.className = this.media.className && this.media.className.replace(allVideoClasses, ' ');
+ this.media.innerHTML = '';
},
/**
* Creates a video node according to the given URL and options. If not
@@ -1123,673 +1088,10 @@ var VideoWidget = MediaWidget.extend({
},
});
-/**
- * MediaDialog widget. Lets users change a media, including uploading a
- * new image, font awsome or video and can change a media into an other
- * media.
- */
-var MediaDialog = Dialog.extend({
- template: 'web_editor.dialog.media',
- xmlDependencies: Dialog.prototype.xmlDependencies.concat(
- ['/web_editor/static/src/xml/editor.xml']
- ),
- events: _.extend({}, Dialog.prototype.events, {
- 'shown.bs.tab a[data-toggle="tab"]': '_onTabChange',
- }),
- custom_events: _.extend({}, Dialog.prototype.custom_events || {}, {
- save_request: '_onSaveRequest',
- }),
-
- /**
- * @constructor
- */
- init: function (parent, options, $editable, media) {
- var self = this;
- this._super(parent, _.extend({}, {
- title: _t("Select a Media"),
- save_text: _t("Add"),
- }, options));
-
- if ($editable) {
- this.$editable = $editable;
- this.rte = this.$editable.rte || this.$editable.data('rte');
- }
-
- this.media = media;
- this.$media = $(media);
- this.range = range.create();
-
- this.multiImages = options.multiImages;
- var onlyImages = options.onlyImages || this.multiImages || (this.media && (this.$media.parent().data('oeField') === 'image' || this.$media.parent().data('oeType') === 'image'));
- this.noImages = options.noImages;
- this.noDocuments = onlyImages || options.noDocuments;
- this.noIcons = onlyImages || options.noIcons;
- this.noVideos = onlyImages || options.noVideos;
-
- if (!this.noImages) {
- this.imageDialog = new ImageWidget(this, this.media, options);
- }
- if (!this.noDocuments) {
- this.documentDialog = new ImageWidget(this, this.media, _.extend({}, options, {document: true}));
- }
- if (!this.noIcons) {
- this.iconDialog = new IconWidget(this, this.media, options);
- }
- if (!this.noVideos) {
- this.videoDialog = new VideoWidget(this, this.media, options);
- }
-
- this.opened(function () {
- var tabToShow = 'icon';
- if (!self.media || self.$media.is('img')) {
- tabToShow = 'image';
- } else if (self.$media.is('a.o_image')) {
- tabToShow = 'document';
- } else if (self.$media.attr('class').match(/(^|\s)media_iframe_video($|\s)/)) {
- tabToShow = 'video';
- } else if (self.$media.parent().attr('class').match(/(^|\s)media_iframe_video($|\s)/)) {
- self.$media = self.$media.parent();
- self.media = self.$media[0];
- tabToShow = 'video';
- }
- self.$('[href="#editor-media-' + tabToShow + '"]').tab('show');
- });
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
- var defs = [this._super.apply(this, arguments)];
- this.$modal.addClass('note-image-dialog');
- this.$modal.find('.modal-dialog').addClass('o_select_media_dialog');
-
- if (this.imageDialog) {
- defs.push(this.imageDialog.appendTo(this.$("#editor-media-image")));
- }
- if (this.documentDialog) {
- defs.push(this.documentDialog.appendTo(this.$("#editor-media-document")));
- }
- if (this.iconDialog) {
- defs.push(this.iconDialog.appendTo(this.$("#editor-media-icon")));
- }
- if (this.videoDialog) {
- defs.push(this.videoDialog.appendTo(this.$("#editor-media-video")));
- }
-
- return $.when.apply($, defs).then(function () {
- self._setActive(self.imageDialog);
- });
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- save: function () {
- var self = this;
- var args = arguments;
- var _super = this._super;
- if (this.multiImages) {
- // In the case of multi images selection we suppose this was not to
- // replace an old media, so we only retrieve the images and save.
- return $.when(this.active.save()).then(function (data) {
- self.final_data = data;
- return _super.apply(self, args);
- });
- }
-
- if (this.rte) {
- this.range.select();
- this.rte.historyRecordUndo(this.media);
- }
-
- if (this.media) {
- this.$media.html('');
- _.each(['imageDialog', 'documentDialog', 'iconDialog', 'videoDialog'], function (v) {
- // Note: hack since imageDialog is the same type as the documentDialog
- if (self[v] && self.active._clear.toString() !== self[v]._clear.toString()) {
- self[v].clear();
- }
- });
- }
-
- return $.when(this.active.save()).then(function (media) {
- if (!self.media && media) {
- self.range.insertNode(media, true);
- }
- self.media = media;
- self.$media = $(media);
-
- self.final_data = self.media;
- $(self.final_data).trigger('input').trigger('save');
- $(document.body).trigger("media-saved", self.final_data); // TODO get rid of this
-
- // Update editor bar after image edition (in case the image change to icon or other)
- _.defer(function () {
- if (!self.media || !self.media.parentNode) {
- return;
- }
- range.createFromNode(self.media).select();
- simulateMouseEvent(self.media, 'mousedown');
- simulateMouseEvent(self.media, 'mouseup');
- });
- return _super.apply(self, args);
- });
- },
-
- //--------------------------------------------------------------------------
- //
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _setActive: function (widget) {
- this.active = widget;
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onSaveRequest: function (ev) {
- ev.stopPropagation();
- this.save();
- },
- /**
- * @private
- */
- _onTabChange: function (ev) {
- var $target = $(ev.target);
- if ($target.is('[href="#editor-media-image"]')) {
- this._setActive(this.imageDialog);
- } else if ($target.is('[href="#editor-media-document"]')) {
- this._setActive(this.documentDialog);
- } else if ($target.is('[href="#editor-media-icon"]')) {
- this._setActive(this.active = this.iconDialog);
- } else if ($target.is('[href="#editor-media-video"]')) {
- this._setActive(this.active = this.videoDialog);
- }
- },
-});
-
-/**
- * Allows to customize link content and style.
- */
-var LinkDialog = Dialog.extend({
- template: 'web_editor.dialog.link',
- xmlDependencies: Dialog.prototype.xmlDependencies.concat(
- ['/web_editor/static/src/xml/editor.xml']
- ),
- events: _.extend({}, Dialog.prototype.events || {}, {
- 'input': '_onAnyChange',
- 'change': '_onAnyChange',
- 'input input[name="url"]': '_onURLInput',
- }),
-
- /**
- * @constructor
- */
- init: function (parent, options, editable, linkInfo) {
- this._super(parent, _.extend({
- title: _t("Link to"),
- }, options || {}));
-
- this.editable = editable;
- this.data = linkInfo || {};
-
- this.data.className = "";
-
- var r = this.data.range;
- this.needLabel = !r || (r.sc === r.ec && r.so === r.eo);
-
- if (this.data.range) {
- this.data.iniClassName = $(this.data.range.sc).filter("a").attr("class") || "";
- this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' ');
-
- var isLink = this.data.range.isOnAnchor();
-
- var sc = r.sc;
- var so = r.so;
- var ec = r.ec;
- var eo = r.eo;
-
- var nodes;
- if (!isLink) {
- if (sc.tagName) {
- sc = dom.firstChild(so ? sc.childNodes[so] : sc);
- so = 0;
- } else if (so !== sc.textContent.length) {
- if (sc === ec) {
- ec = sc = sc.splitText(so);
- eo -= so;
- } else {
- sc = sc.splitText(so);
- }
- so = 0;
- }
- if (ec.tagName) {
- ec = dom.lastChild(eo ? ec.childNodes[eo - 1] : ec);
- eo = ec.textContent.length;
- } else if (eo !== ec.textContent.length) {
- ec.splitText(eo);
- }
-
- nodes = dom.listBetween(sc, ec);
-
- // browsers can't target a picture or void node
- if (dom.isVoid(sc) || dom.isImg(sc)) {
- so = dom.listPrev(sc).length - 1;
- sc = sc.parentNode;
- }
- if (dom.isBR(ec)) {
- eo = dom.listPrev(ec).length - 1;
- ec = ec.parentNode;
- } else if (dom.isVoid(ec) || dom.isImg(sc)) {
- eo = dom.listPrev(ec).length;
- ec = ec.parentNode;
- }
-
- this.data.range = range.create(sc, so, ec, eo);
- this.data.range.select();
- } else {
- nodes = dom.ancestor(sc, dom.isAnchor).childNodes;
- }
-
- if (dom.isImg(sc) && nodes.indexOf(sc) === -1) {
- nodes.push(sc);
- }
- if (nodes.length > 1 || dom.ancestor(nodes[0], dom.isImg)) {
- var text = "";
- this.data.images = [];
- for (var i = 0; i < nodes.length; i++) {
- if (dom.ancestor(nodes[i], dom.isImg)) {
- this.data.images.push(dom.ancestor(nodes[i], dom.isImg));
- text += '[IMG]';
- } else if (!isLink && nodes[i].nodeType === 1) {
- // just use text nodes from listBetween
- } else if (!isLink && i === 0) {
- text += nodes[i].textContent.slice(so, Infinity);
- } else if (!isLink && i === nodes.length - 1) {
- text += nodes[i].textContent.slice(0, eo);
- } else {
- text += nodes[i].textContent;
- }
- }
- this.data.text = text;
- }
- }
-
- this.data.text = this.data.text.replace(/[ \t\r\n]+/g, ' ');
-
- this._onURLInput = _.debounce(this._onURLInput, 400);
- },
- /**
- * @override
- */
- start: function () {
- var self = this;
-
- this.$('input.link-style').prop('checked', false).first().prop('checked', true);
- if (this.data.iniClassName) {
- _.each(this.$('input.link-style, select.link-style > option'), function (el) {
- var $option = $(el);
- if ($option.val() && self.data.iniClassName.indexOf($option.val()) >= 0) {
- if ($option.is("input")) {
- $option.prop("checked", true);
- } else {
- $option.parent().val($option.val());
- }
- }
- });
- }
- if (this.data.url) {
- var match = /mailto:(.+)/.exec(this.data.url);
- this.$('input[name="url"]').val(match ? match[1] : this.data.url);
- }
-
- // Hide the duplicate color buttons (most of the times, primary = alpha
- // and secondary = beta for example but this may depend on the theme)
- this.opened().then(function () {
- var colors = [];
- _.each(self.$('.o_btn_preview.o_link_dialog_color_item'), function (btn) {
- var $btn = $(btn);
- var color = $btn.css('background-color');
- if (_.contains(colors, color)) {
- $btn.hide(); // Not remove to be able to edit buttons with those styles
- } else {
- colors.push(color);
- }
- });
- });
-
- this._adaptPreview();
-
- this.$('input:visible:first').focus();
-
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @override
- */
- save: function () {
- var data = this._getData();
- if (data === null) {
- var $url = this.$('input[name="url"]');
- $url.closest('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
- $url.focus();
- return $.Deferred().reject();
- }
- this.data.text = data.label;
- this.data.url = data.url;
- this.data.className = data.classes.replace(/\s+/gi, ' ').replace(/^\s+|\s+$/gi, '');
- if (data.classes.replace(/(^|[ ])(btn-secondary|btn-success|btn-primary|btn-info|btn-warning|btn-danger)([ ]|$)/gi, ' ')) {
- this.data.style = {'background-color': '', 'color': ''};
- }
- this.data.isNewWindow = data.isNewWindow;
- this.final_data = this.data;
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _adaptPreview: function () {
- var $preview = this.$("#link-preview");
- var data = this._getData();
- if (data === null) {
- return;
- }
- $preview.attr({
- target: data.isNewWindow ? '_blank' : '',
- href: data.url && data.url.length ? data.url : '#',
- class: data.classes.replace(/float-\w+/, '') + ' o_btn_preview',
- }).html((data.label && data.label.length) ? data.label : data.url);
- },
- /**
- * @private
- */
- _getData: function () {
- var $url = this.$('input[name="url"]');
- var url = $url.val();
- var label = this.$('input[name="label"]').val() || url;
-
- if (label && this.data.images) {
- for (var i = 0; i < this.data.images.length; i++) {
- label = label.replace(/</, "&lt;").replace(/>/, "&gt;").replace(/\[IMG\]/, this.data.images[i].outerHTML);
- }
- }
-
- if ($url.prop('required') && (!url || !$url[0].checkValidity())) {
- return null;
- }
-
- var style = this.$('input[name="link_style_color"]:checked').val() || '';
- var shape = this.$('select[name="link_style_shape"]').val() || '';
- var size = this.$('select[name="link_style_size"]').val() || '';
- var shapes = shape.split(',');
- var outline = shapes[0] === 'outline';
- shape = shapes.slice(outline ? 1 : 0).join(' ');
- var classes = (this.data.className || '')
- + (style ? (' btn btn-' + (outline ? 'outline-' : '') + style) : '')
- + (shape ? (' ' + shape) : '')
- + (size ? (' btn-' + size) : '');
- var isNewWindow = this.$('input[name="is_new_window"]').prop('checked');
-
- if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) {
- url = ('mailto:' + url);
- }
- return {
- label: label,
- url: url,
- classes: classes,
- isNewWindow: isNewWindow,
- };
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- */
- _onAnyChange: function () {
- this._adaptPreview();
- },
- /**
- * @private
- */
- _onURLInput: function () {
- var $linkUrlInput = this.$('#o_link_dialog_url_input');
- $linkUrlInput.closest('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
- var isLink = $linkUrlInput.val().indexOf('@') < 0;
- this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink);
- },
-});
-
-/**
- * CropImageDialog widget. Let users crop an image.
- */
-var CropImageDialog = Dialog.extend({
- template: 'web_editor.dialog.crop_image',
- xmlDependencies: Dialog.prototype.xmlDependencies.concat(
- ['/web_editor/static/src/xml/editor.xml']
- ),
- jsLibs: [
- '/web_editor/static/lib/cropper/js/cropper.js',
- ],
- cssLibs: [
- '/web_editor/static/lib/cropper/css/cropper.css',
- ],
- events: _.extend({}, Dialog.prototype.events, {
- 'click .o_crop_options [data-event]': '_onCropOptionClick',
- }),
-
- /**
- * @constructor
- */
- init: function (parent, options, $editable, media) {
- this.media = media;
- this.$media = $(this.media);
- var src = this.$media.attr('src').split('?')[0];
- this.aspectRatioList = [
- [_t("Free"), '0/0', 0],
- ["16:9", '16/9', 16 / 9],
- ["4:3", '4/3', 4 / 3],
- ["1:1", '1/1', 1],
- ["2:3", '2/3', 2 / 3],
- ];
- this.imageData = {
- imageSrc: src,
- originalSrc: this.$media.data('crop:originalSrc') || src, // the original src for cropped DB images will be fetched later
- mimetype: this.$media.data('crop:mimetype') || (_.str.endsWith(src, '.png') ? 'image/png' : 'image/jpeg'), // the mimetype for DB images will be fetched later
- aspectRatio: this.$media.data('aspectRatio') || this.aspectRatioList[0][1],
- isExternalImage: src.substr(0, 5) !== 'data:' && src[0] !== '/' && src.indexOf(window.location.host) < 0,
- };
- this.options = _.extend({
- title: _t("Crop Image"),
- buttons: this.imageData.isExternalImage ? [{
- text: _t("Close"),
- close: true,
- }] : [{
- text: _t("Save"),
- classes: 'btn-primary',
- click: this.save,
- }, {
- text: _t("Discard"),
- close: true,
- }],
- }, options || {});
- this._super(parent, this.options);
- },
- /**
- * @override
- */
- willStart: function () {
- var self = this;
- var def = this._super.apply(this, arguments);
- if (this.imageData.isExternalImage) {
- return def;
- }
-
- var defs = [def, ajax.loadLibs(this)];
- var params = {};
- var isDBImage = false;
- var matchImageID = this.imageData.imageSrc.match(/^\/web\/image\/(\d+)/);
- if (matchImageID) {
- params['image_id'] = parseInt(matchImageID[1]);
- isDBImage = true;
- } else {
- var matchXmlID = this.imageData.imageSrc.match(/^\/web\/image\/([^/?]+)/);
- if (matchXmlID) {
- params['xml_id'] = matchXmlID[1];
- isDBImage = true;
- }
- }
- if (isDBImage) {
- defs.push(this._rpc({
- route: '/web_editor/get_image_info',
- params: params,
- }).then(function (res) {
- _.extend(self.imageData, res);
- }));
- }
- return $.when.apply($, defs);
- },
- /**
- * @override
- */
- start: function () {
- this.$cropperImage = this.$('.o_cropper_image');
- if (this.$cropperImage.length) {
- var data = this.$media.data();
- var ratio = 0;
- for (var i = 0; i < this.aspectRatioList.length; i++) {
- if (this.aspectRatioList[i][1] === data.aspectRatio) {
- ratio = this.aspectRatioList[i][2];
- break;
- }
- }
- this.$cropperImage.cropper({
- viewMode: 1,
- autoCropArea: 1,
- aspectRatio: ratio,
- data: _.pick(data, 'x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY')
- });
- }
- return this._super.apply(this, arguments);
- },
- /**
- * @override
- */
- destroy: function () {
- if (this.$cropperImage.length) {
- this.$cropperImage.cropper('destroy');
- }
- this._super.apply(this, arguments);
- },
- /**
- * Updates the DOM image with cropped data and associates required
- * information for a potential future save (where required cropped data
- * attachments will be created).
- *
- * @override
- */
- save: function () {
- var self = this;
- var cropperData = this.$cropperImage.cropper('getData');
-
- // Mark the media for later creation of required cropped attachments...
- this.$media.addClass('o_cropped_img_to_save');
-
- // ... and attach required data
- this.$media.data('crop:resModel', this.options.res_model);
- this.$media.data('crop:resID', this.options.res_id);
- this.$media.data('crop:id', this.imageData.id);
- this.$media.data('crop:mimetype', this.imageData.mimetype);
- this.$media.data('crop:originalSrc', this.imageData.originalSrc);
-
- // Mark the media with the cropping information which is required for
- // a future crop edition
- this.$media
- .attr('data-aspect-ratio', this.imageData.aspectRatio)
- .data('aspectRatio', this.imageData.aspectRatio);
- _.each(cropperData, function (value, key) {
- key = _.str.dasherize(key);
- self.$media.attr('data-' + key, value);
- self.$media.data(key, value);
- });
-
- // Update the media with base64 source for preview before saving
- var canvas = this.$cropperImage.cropper('getCroppedCanvas', {
- width: cropperData.width,
- height: cropperData.height,
- });
- this.$media.attr('src', canvas.toDataURL(this.imageData.mimetype));
-
- this.$media.trigger('content_changed');
-
- return this._super.apply(this, arguments);
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Called when a crop option is clicked -> change the crop area accordingly.
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onCropOptionClick: function (ev) {
- var $option = $(ev.currentTarget);
- var opt = $option.data('event');
- var value = $option.data('value');
- switch (opt) {
- case 'ratio':
- this.$cropperImage.cropper('reset');
- this.imageData.aspectRatio = $option.data('label');
- this.$cropperImage.cropper('setAspectRatio', value);
- break;
- case 'zoom':
- case 'rotate':
- case 'reset':
- this.$cropperImage.cropper(opt, value);
- break;
- case 'flip':
- var direction = value === 'horizontal' ? 'x' : 'y';
- var scaleAngle = -$option.data(direction);
- $option.data(direction, scaleAngle);
- this.$cropperImage.cropper('scale' + direction.toUpperCase(), scaleAngle);
- break;
- }
- },
-});
-
return {
- Dialog: Dialog,
- AltDialog: AltDialog,
- MediaDialog: MediaDialog,
- LinkDialog: LinkDialog,
- CropImageDialog: CropImageDialog,
+ MediaWidget: MediaWidget,
ImageWidget: ImageWidget,
+ IconWidget: IconWidget,
+ VideoWidget: VideoWidget,
};
});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
new file mode 100644
index 00000000000..e6cb1277a7e
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
@@ -0,0 +1,207 @@
+odoo.define('wysiwyg.widgets.MediaDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var MediaModules = require('wysiwyg.widgets.media');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var _t = core._t;
+
+/**
+ * MediaDialog widget. Lets users change a media, including uploading a
+ * new image, font awsome or video and can change a media into an other
+ * media.
+ */
+var MediaDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.media',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/editor.xml']
+ ),
+ events: _.extend({}, Dialog.prototype.events, {
+ 'click a[data-toggle="tab"]': '_onTabChange',
+ }),
+ custom_events: _.extend({}, Dialog.prototype.custom_events || {}, {
+ save_request: '_onSaveRequest',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, media) {
+ var self = this;
+ options = options || {};
+
+ this._super(parent, _.extend({}, {
+ title: _t("Select a Media"),
+ save_text: _t("Add"),
+ }, options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ type: 'media',
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.media = media;
+ this.$media = $(media);
+
+ this.multiImages = options.multiImages;
+ var onlyImages = options.onlyImages || this.multiImages || (this.media && (this.$media.parent().data('oeField') === 'image' || this.$media.parent().data('oeType') === 'image'));
+ this.noImages = options.noImages;
+ this.noDocuments = onlyImages || options.noDocuments;
+ this.noIcons = onlyImages || options.noIcons;
+ this.noVideos = onlyImages || options.noVideos;
+
+ if (!this.noDocuments) {
+ this.documentDialog = new MediaModules.ImageWidget(this, this.media, _.extend({}, options, {
+ document: true,
+ }));
+ this.documentDialog.tabToShow = 'document';
+ }
+ if (!this.noIcons) {
+ this.iconDialog = new MediaModules.IconWidget(this, this.media, options);
+ this.iconDialog.tabToShow = 'icon';
+ }
+ if (!this.noVideos) {
+ this.videoDialog = new MediaModules.VideoWidget(this, this.media, options);
+ this.videoDialog.tabToShow = 'video';
+ }
+ if (!this.noImages) {
+ this.imageDialog = new MediaModules.ImageWidget(this, this.media, options);
+ this.imageDialog.tabToShow = 'image';
+ }
+
+ this.active = this.imageDialog || this.documentDialog || this.iconDialog || this.videoDialog;
+ if (this.imageDialog && this.$media.is('img')) {
+ this.active = this.imageDialog;
+ } else if (this.documentDialog && this.$media.is('a.o_image')) {
+ this.active = this.documentDialog;
+ } else if (this.videoDialog && this.$media.hasClass('media_iframe_video')) {
+ this.active = this.videoDialog;
+ } else if (this.videoDialog && this.$media.parent().hasClass('media_iframe_video')) {
+ this.$media = this.$media.parent();
+ this.media = this.$media[0];
+ this.active = this.videoDialog;
+ } else if (this.iconDialog && this.$media.is('span, i')) {
+ this.active = this.iconDialog;
+ }
+
+ this.opened(function () {
+ self.$('[href="#editor-media-' + self.active.tabToShow + '"]').tab('show');
+ });
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+ this.$modal.addClass('note-image-dialog');
+ this.$modal.find('.modal-dialog').addClass('o_select_media_dialog');
+
+ if (this.imageDialog) {
+ this.imageDialog.clear();
+ }
+ if (this.documentDialog) {
+ this.documentDialog.clear();
+ }
+ if (this.iconDialog) {
+ this.iconDialog.clear();
+ }
+ if (this.videoDialog) {
+ this.videoDialog.clear();
+ }
+
+ if (this.imageDialog) {
+ defs.push(this.imageDialog.appendTo(this.$("#editor-media-image")));
+ }
+ if (this.documentDialog) {
+ defs.push(this.documentDialog.appendTo(this.$("#editor-media-document")));
+ }
+ if (this.iconDialog) {
+ defs.push(this.iconDialog.appendTo(this.$("#editor-media-icon")));
+ }
+ if (this.videoDialog) {
+ defs.push(this.videoDialog.appendTo(this.$("#editor-media-video")));
+ }
+
+ return $.when.apply($, defs).then(function () {
+ self._setActive(self.active);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var self = this;
+ var args = arguments;
+ var _super = this._super;
+ if (this.multiImages) {
+ // In the case of multi images selection we suppose this was not to
+ // replace an old media, so we only retrieve the images and save.
+ return $.when(this.active.save()).then(function (data) {
+ self.final_data = data;
+ return _super.apply(self, args);
+ });
+ }
+
+ return $.when(this.active.save()).then(function (media) {
+ self.trigger('saved', {
+ attachments: self.active.images,
+ media: media,
+ });
+ return _super.apply(self, args);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ //
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} widget
+ */
+ _setActive: function (widget) {
+ this.active = widget;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSaveRequest: function (ev) {
+ ev.stopPropagation();
+ this.save();
+ },
+ /**
+ * @private
+ * @param {JQueryEvent} ev
+ */
+ _onTabChange: function (ev) {
+ var $target = $(ev.target);
+ if ($target.is('[href="#editor-media-image"]')) {
+ this._setActive(this.imageDialog);
+ } else if ($target.is('[href="#editor-media-document"]')) {
+ this._setActive(this.documentDialog);
+ } else if ($target.is('[href="#editor-media-icon"]')) {
+ this._setActive(this.active = this.iconDialog);
+ } else if ($target.is('[href="#editor-media-video"]')) {
+ this._setActive(this.active = this.videoDialog);
+ }
+ },
+});
+
+return MediaDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
new file mode 100644
index 00000000000..18b88951c70
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
@@ -0,0 +1,26 @@
+odoo.define('wysiwyg.widgets', function (require) {
+'use strict';
+
+var Dialog = require('wysiwyg.widgets.Dialog');
+var AltDialog = require('wysiwyg.widgets.AltDialog');
+var MediaDialog = require('wysiwyg.widgets.MediaDialog');
+var LinkDialog = require('wysiwyg.widgets.LinkDialog');
+var CropImageDialog = require('wysiwyg.widgets.CropImageDialog');
+var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog');
+
+var media = require('wysiwyg.widgets.media');
+
+return {
+ Dialog: Dialog,
+ AltDialog: AltDialog,
+ MediaDialog: MediaDialog,
+ LinkDialog: LinkDialog,
+ CropImageDialog: CropImageDialog,
+ ColorpickerDialog: ColorpickerDialog,
+
+ MediaWidget: media.MediaWidget,
+ ImageWidget: media.ImageWidget,
+ IconWidget: media.IconWidget,
+ VideoWidget: media.VideoWidget,
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
new file mode 100644
index 00000000000..b8ff583c6fa
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -0,0 +1,821 @@
+odoo.define('web_editor.wysiwyg', function (require) {
+'use strict';
+
+var Widget = require('web.Widget');
+var config = require('web.config');
+var core = require('web.core');
+var session = require('web.session');
+var modulesRegistry = require('web_editor.wysiwyg.plugin.registry');
+var wysiwygOptions = require('web_editor.wysiwyg.options');
+
+var _t = core._t;
+
+var Wysiwyg = Widget.extend({
+ xmlDependencies: [
+ '/web_editor/static/src/xml/wysiwyg.xml',
+ ],
+ custom_events: {
+ getRecordInfo: '_onGetRecordInfo',
+ wysiwyg_blur: '_onWysiwygBlur',
+ },
+ defaultOptions: {
+ codeview: config.debug
+ },
+
+ /**
+ * @params {Object} params
+ * @params {Object} params.recordInfo
+ * @params {Object} params.recordInfo.context
+ * @params {String} [params.recordInfo.context]
+ * @params {integer} [params.recordInfo.res_id]
+ * @params {String} [params.recordInfo.data_res_model]
+ * @params {integer} [params.recordInfo.data_res_id]
+ * @see _onGetRecordInfo
+ * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js
+ * @params {Object} params.attachments
+ * @see _onGetRecordInfo
+ * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js (for attachmentIDs)
+ * @params {function} params.generateOptions
+ * called with the summernote configuration object used before sending to summernote
+ * @see _editorOptions
+ **/
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+ this.options = _.extend({}, this.defaultOptions, params);
+ this.attachments = this.options.attachments || [];
+ this.hints = [];
+ this.$el = null;
+ this._dirty = false;
+ this.id = _.uniqueId('wysiwyg_');
+ },
+ /**
+ * Load assets and color picker template then call summernote API
+ * and replace $el by the summernote editable node.
+ *
+ * @override
+ **/
+ willStart: function () {
+ var self = this;
+ this.$target = this.$el;
+ this.$el = null; // temporary null to avoid hidden error, setElement when start
+ return this._super()
+ .then(function () {
+ return modulesRegistry.start(self).then(function () {
+ return self._loadInstance();
+ });
+ });
+ },
+ /**
+ *
+ * @override
+ */
+ start: function () {
+ var value = this._summernote.code();
+ this._value = value;
+ if (this._summernote.invoke('HelperPlugin.hasJinja', value)) {
+ this._summernote.invoke('codeview.forceActivate');
+ }
+ return $.when();
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this._summernote) {
+ // prevents the replacement of the target by the content of summernote
+ // (in order to be able to cancel)
+ var removeLayout = $.summernote.ui.removeLayout;
+ $.summernote.ui.removeLayout = function ($note, layoutInfo) {
+ layoutInfo.editor.remove();
+ $note.show();
+ };
+ this._summernote.destroy();
+ $.summernote.ui.removeLayout = removeLayout;
+ }
+ this.$target.removeAttr('data-wysiwyg-id');
+ this.$target.removeData('wysiwyg');
+ $(document).off('.' + this.id);
+ this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a step (undo) in editor.
+ */
+ addHistoryStep: function () {
+ var editor = this._summernote.modules.editor;
+ editor.createRange();
+ editor.history.recordUndo();
+ },
+ /**
+ * Return the editable area.
+ *
+ * @returns {jQuery}
+ */
+ getEditable: function () {
+ if (this._summernote.invoke('HelperPlugin.hasJinja', this._summernote.code())) {
+ return this._summernote.layoutInfo.codable;
+ } else if (this._summernote.invoke('codeview.isActivated')) {
+ this._summernote.invoke('codeview.deactivate');
+ }
+ return this._summernote.layoutInfo.editable;
+ },
+ /**
+ * Perform undo or redo in the editor.
+ *
+ * @param {integer} step
+ */
+ history: function (step) {
+ if (step < 0) {
+ while (step) {
+ this._summernote.modules.editor.history.rewind();
+ step++;
+ }
+ } else if (step > 0) {
+ while (step) {
+ this._summernote.modules.editor.history.redo();
+ step--;
+ }
+ }
+ },
+ /**
+ * Return true if the content has changed.
+ *
+ * @returns {Boolean}
+ */
+ isDirty: function () {
+ if (!this._dirty && this._value !== this._summernote.code()) {
+ console.warn("not dirty flag ? Please fix it.");
+ }
+ return this._value !== this._summernote.code();
+ },
+ /**
+ * Return true if the current node is unbreakable.
+ * An unbreakable node can be removed or added but can't by split into
+ * different nodes (for keypress and selection).
+ * An unbreakable node can contain nodes that can be edited.
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ isUnbreakableNode: function (node) {
+ return ["TD", "TR", "TBODY", "TFOOT", "THEAD", "TABLE"].indexOf(node.tagName) !== -1 || $(node).is(this.getEditable()) ||
+ !this.isEditableNode(node.parentNode) || !this.isEditableNode(node) || $.summernote.dom.isMedia(node);
+ },
+ /**
+ * Return true if the current node is editable (for keypress and selection).
+ *
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ isEditableNode: function (node) {
+ return $(node).is(':o_editable') && !$(node).is('table, thead, tbody, tfoot, tr');
+ },
+ /**
+ * Set the focus on the element.
+ */
+ focus: function () {
+ this.$el.mousedown();
+ },
+ /**
+ * Get the value of the editable element.
+ *
+ * @param {object} [options]
+ * @param {Boolean} [options.keepPopover]
+ * @returns {String}
+ */
+ getValue: function (options) {
+ if (!options || !options.keepPopover) {
+ this._summernote.invoke('editor.hidePopover');
+ }
+ var $editable = this.getEditable().clone();
+ $editable.find('.o_wysiwyg_to_remove').remove();
+ $editable.find('[contenteditable]').removeAttr('contenteditable');
+ $editable.find('.o_fake_not_editable').removeClass('o_fake_not_editable');
+ $editable.find('.o_fake_editable').removeClass('o_fake_editable');
+ $editable.find('[class=""]').removeAttr('class');
+ $editable.find('[style=""]').removeAttr('style');
+ $editable.find('[title=""]').removeAttr('title');
+ $editable.find('[alt=""]').removeAttr('alt');
+ $editable.find('a.o_image, span.fa, i.fa').html('');
+ $editable.find('[aria-describedby]').removeAttr('aria-describedby').removeAttr('data-original-title');
+
+ return $editable.html() || $editable.val();
+ },
+ /**
+ * Save the content in the target
+ * - in init option beforeSave
+ * - receive editable jQuery DOM as attribute
+ * - called after deactivate codeview if needed
+ * @returns {$.Promise}
+ * - resolve with true if the content was dirty
+ */
+ save: function () {
+ var isDirty = this.isDirty();
+ var html = this.getValue();
+ if (this.$target.is('textarea')) {
+ this.$target.val(html);
+ } else {
+ this.$target.html(html);
+ }
+ return $.when(isDirty, html);
+ },
+ /**
+ * @param {String} value
+ * @param {Object} options
+ * @param {Boolean} [options.notifyChange]
+ * @returns {String}
+ */
+ setValue: function (value, options) {
+ if (this._summernote.invoke('HelperPlugin.hasJinja', value)) {
+ this._summernote.invoke('codeview.forceActivate');
+ }
+ this._dirty = true;
+ this._summernote.invoke('HistoryPlugin.clear');
+ this._summernote.invoke('editor.hidePopover');
+ this._summernote.invoke('editor.clearTarget');
+ var $editable = this.getEditable().html(value + '');
+ this._summernote.invoke('UnbreakablePlugin.secureArea');
+ if (!options || options.notifyChange !== false) {
+ $editable.change();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Object} the summernote configuration
+ */
+ _editorOptions: function () {
+ var self = this;
+ var allowAttachment = !this.options.noAttachment;
+
+ var options = JSON.parse(JSON.stringify(wysiwygOptions));
+
+ options.parent = this;
+ options.lang = "odoo";
+
+ options.focus = false;
+ options.disableDragAndDrop = !allowAttachment;
+ options.styleTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre'];
+ options.fontSizes = [_t('Default'), '8', '9', '10', '11', '12', '13', '14', '18', '24', '36', '48', '62'];
+ options.minHeight = 180;
+
+ options.keyMap.pc['CTRL+K'] = 'LinkPlugin.show';
+ options.keyMap.mac['CMD+K'] = 'LinkPlugin.show';
+ delete options.keyMap.pc['CTRL+LEFTBRACKET'];
+ delete options.keyMap.mac['CMD+LEFTBRACKET'];
+ delete options.keyMap.pc['CTRL+RIGHTBRACKET'];
+ delete options.keyMap.mac['CMD+RIGHTBRACKET'];
+
+ options.toolbar = [
+ ['style', ['style']],
+ ['font', ['bold', 'italic', 'underline', 'clear']],
+ ['fontsize', ['fontsize']],
+ ['color', ['colorpicker']],
+ ['para', ['ul', 'ol', 'paragraph']],
+ ['table', ['table']],
+ ['insert', allowAttachment ? ['linkPlugin', 'mediaPlugin'] : ['linkPlugin']],
+ ['history', ['undo', 'redo']],
+ ['view', this.options.codeview ? ['fullscreen', 'codeview', 'help'] : ['fullscreen', 'help']]
+ ];
+ options.popover = {
+ image: [
+ ['padding'],
+ ['imagesize', ['imageSizeAuto', 'imageSize100', 'imageSize50', 'imageSize25']],
+ ['float', ['alignLeft', 'alignCenter', 'alignRight', 'alignNone']],
+ ['imageShape'],
+ ['cropImage'],
+ ['media', ['mediaPlugin', 'removePluginMedia']],
+ ['alt']
+ ],
+ video: [
+ ['padding'],
+ ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']],
+ ['float', ['alignLeft', 'alignCenter', 'alignRight', 'alignNone']],
+ ['media', ['mediaPlugin', 'removePluginMedia']]
+ ],
+ icon: [
+ ['padding'],
+ ['faSize'],
+ ['float', ['alignLeft', 'alignCenter', 'alignRight', 'alignNone']],
+ ['faSpin'],
+ ['media', ['mediaPlugin', 'removePluginMedia']]
+ ],
+ document: [
+ ['float', ['alignLeft', 'alignCenter', 'alignRight', 'alignNone']],
+ ['media', ['mediaPlugin', 'removePluginMedia']]
+ ],
+ link: [
+ ['link', ['linkPlugin', 'unlink']]
+ ],
+ table: [
+ ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],
+ ['delete', ['deleteRow', 'deleteCol', 'deleteTable']]
+ ],
+ };
+
+ options.callbacks = {
+ onBlur: this._onBlurEditable.bind(this),
+ onFocus: this._onFocusEditable.bind(this),
+ onChange: this._onChange.bind(this),
+ onImageUpload: this._onImageUpload.bind(this),
+ onFocusnode: this._onFocusnode.bind(this),
+ };
+
+ options.isUnbreakableNode = function (node) {
+ node = node && (node.tagName ? node : node.parentNode);
+ if (!node) {
+ return true;
+ }
+ return self.isUnbreakableNode(node);
+ };
+ options.isEditableNode = function (node) {
+ node = node && (node.tagName ? node : node.parentNode);
+ if (!node) {
+ return false;
+ }
+ return self.isEditableNode(node);
+ };
+ options.displayPopover = this._isDisplayingPopover.bind(this);
+ options.hasFocus = function () {
+ return self._isFocused;
+ };
+
+ if (this.options.generateOptions) {
+ this.options.generateOptions(options);
+ }
+
+ return options;
+ },
+ /**
+ * @private
+ * @returns {Object} modules list to load
+ */
+ _getPlugins: function () {
+ return _.extend({}, wysiwygOptions.modules, modulesRegistry.plugins());
+ },
+ /**
+ * Return an object describing the linked record.
+ *
+ * @private
+ * @param {Object} options
+ * @returns {Object} {res_id, res_model, xpath}
+ */
+ _getRecordInfo: function (options) {
+ var data = this.options.recordInfo || {};
+ if (typeof data === 'function') {
+ data = data(options);
+ }
+ if (!data.context) {
+ throw new Error("Context is missing");
+ }
+ return data;
+ },
+ /**
+ * Return true if the editor is displaying the popover.
+ *
+ * @private
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ _isDisplayingPopover: function (node) {
+ return true;
+ },
+ /**
+ * Return true if the given node is in the editor.
+ * Note: a button in the MediaDialog returns true.
+ *
+ * @private
+ * @param {Node} node
+ * @returns {Boolean}
+ */
+ _isEditorContent: function (node) {
+ if (this.el === node) {
+ return true;
+ }
+ if ($.contains(this.el, node)) {
+ return true;
+ }
+
+ var children = this.getChildren();
+ var allChildren = [];
+ var child;
+ while ((child = children.pop())) {
+ allChildren.push(child);
+ children = children.concat(child.getChildren());
+ }
+
+ var childrenDom = _.filter(_.unique(_.flatten(_.map(allChildren, function (obj) {
+ return _.map(obj, function (value) {
+ return value instanceof $ ? value.get() : value;
+ });
+ }))), function (node) {
+ return node && node.DOCUMENT_NODE && node.tagName && node.tagName !== 'BODY' && node.tagName !== 'HTML';
+ });
+ return !!$(node).closest(childrenDom).length;
+ },
+ /**
+ * Create an instance with the API lib.
+ *
+ * @private
+ * @returns {$.Promise}
+ */
+ _loadInstance: function () {
+ var defaultOptions = this._editorOptions();
+ var summernoteOptions = _.extend({
+ id: this.id,
+ }, defaultOptions, _.omit(this.options, 'isEditableNode', 'isUnbreakableNode'));
+
+ _.extend(summernoteOptions.callbacks, defaultOptions.callbacks, this.options.callbacks);
+ if (this.options.keyMap) {
+ _.defaults(summernoteOptions.keyMap.pc, defaultOptions.keyMap.pc);
+ _.each(summernoteOptions.keyMap.pc, function (v, k, o) {
+ if (!v) {
+ delete o[k];
+ }
+ });
+ _.defaults(summernoteOptions.keyMap.mac, defaultOptions.keyMap.mac);
+ _.each(summernoteOptions.keyMap.mac, function (v, k, o) {
+ if (!v) {
+ delete o[k];
+ }
+ });
+ }
+
+ var plugins = _.extend(this._getPlugins(), this.options.plugins);
+ summernoteOptions.modules = _.omit(plugins, function (v) {
+ return !v;
+ });
+
+ if (this.$target.parent().length) {
+ summernoteOptions.container = this.$target.parent().css('position', 'relative')[0];
+ } else {
+ summernoteOptions.container = this.$target[0].ownerDocument.body;
+ }
+
+ this.$target.summernote(summernoteOptions);
+
+ this._summernote = this.$target.data('summernote');
+ this._summernote.layoutInfo.editable.data('wysiwyg', this);
+ this.$target.attr('data-wysiwyg-id', this.id).data('wysiwyg', this);
+ $('.note-editor, .note-popover').not('[data-wysiwyg-id]').attr('data-wysiwyg-id', this.id);
+
+ this.setElement(this._summernote.layoutInfo.editor);
+ $(document).on('mousedown.' + this.id, this._onMouseDown.bind(this));
+ $(document).on('mouseenter.' + this.id, '*', this._onMouseEnter.bind(this));
+ $(document).on('mouseleave.' + this.id, '*', this._onMouseLeave.bind(this));
+ $(document).on('mousemove.' + this.id, this._onMouseMove.bind(this));
+
+ this.$el.removeClass('card');
+
+ return $.when();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * trigger_up 'wysiwyg_focus'.
+ *
+ * @private
+ * @param {Object} [options]
+ */
+ _onFocus: function (options) {
+ this.trigger_up('wysiwyg_focus', options);
+ },
+ /**
+ * trigger_up 'wysiwyg_blur'.
+ *
+ * @private
+ * @param {Object} [options]
+ */
+ _onBlur: function (options) {
+ this.trigger_up('wysiwyg_blur', options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onMouseEnter: function (ev) {
+ if (this._isFocused && !this._mouseInEditor && this._isEditorContent(ev.target)) {
+ this._mouseInEditor = true;
+ }
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onMouseLeave: function (ev) {
+ if (this._isFocused && this._mouseInEditor) {
+ this._mouseInEditor = null;
+ }
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onMouseMove: function (ev) {
+ if (this._mouseInEditor === null) {
+ this._mouseInEditor = !!this._isEditorContent(ev.target);
+ }
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onMouseDown: function (ev) {
+ var self = this;
+ if (this._isEditorContent(ev.target)) {
+ setTimeout(function () {
+ if (!self._editableHasFocus && !self._isEditorContent(document.activeElement)) {
+ self._summernote.layoutInfo.editable.focus();
+ }
+ if (!self._isFocused) {
+ self._isFocused = true;
+ self._onFocus();
+ }
+ });
+ } else if (this._isFocused) {
+ this._isFocused = false;
+ this._onBlur();
+ }
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onBlurEditable: function (ev) {
+ var self = this;
+ this._editableHasFocus = false;
+ if (!this._isFocused) {
+ return;
+ }
+ if (!this._justFocused && !this._mouseInEditor) {
+ if (this._isFocused) {
+ this._isFocused = false;
+ this._onBlur();
+ }
+ } else if (!this._forceEditableFocus) {
+ this._forceEditableFocus = true;
+ setTimeout(function () {
+ if (!self._isEditorContent(document.activeElement)) {
+ self._summernote.layoutInfo.editable.focus();
+ }
+ self._forceEditableFocus = false; // prevent stack size exceeded.
+ });
+ } else {
+ this._mouseInEditor = null;
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onWysiwygBlur: function (ev) {
+ if (ev.target === this) {
+ return;
+ }
+ ev.stopPropagation();
+ this._isFocused = false;
+ this._forceEditableFocus = false;
+ this._mouseInEditor = false;
+ this._summernote.disable();
+ this.$target.focus();
+ setTimeout(this._summernote.enable.bind(this._summernote));
+ this._onBlur(ev.data);
+ },
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onFocusEditable: function (ev) {
+ var self = this;
+ this._editableHasFocus = true;
+ this._justFocused = true;
+ setTimeout(function () {
+ self._justFocused = true;
+ });
+ },
+ /**
+ * trigger_up 'wysiwyg_change'
+ *
+ * @private
+ */
+ _onChange: function () {
+ var html = this._summernote.code();
+ if (this.hints.length) {
+ var hints = [];
+ _.each(this.hints, function (hint) {
+ if (html.indexOf('@' + hint.name) !== -1) {
+ hints.push(hint);
+ }
+ });
+ this.hints = hints;
+ }
+
+ this._dirty = true;
+ this.trigger_up('wysiwyg_change', {
+ html: html,
+ hints: this.hints,
+ attachments: this.attachments,
+ });
+ },
+ /**
+ * trigger_up 'wysiwyg_attachment' when add an image found in the view.
+ *
+ * This method is called when an image is uploaded by the media dialog and returns the
+ * object attachment as recorded in the "ir.attachment" model, via a wysiwyg_attachment event.
+ *
+ * For e.g. when sending email, this allows people to add attachments with the content
+ * editor interface and that they appear in the attachment list.
+ * The new documents being attached to the email, they will not be erased by the CRON
+ * when closing the wizard.
+ *
+ * @private
+ */
+ _onImageUpload: function (attachments) {
+ var self = this;
+ attachments = _.filter(attachments, function (attachment) {
+ return !_.findWhere(self.attachments, {
+ id: attachment.id,
+ });
+ });
+ if (!attachments.length) {
+ return;
+ }
+ this.attachments = this.attachments.concat(attachments);
+
+ // todo remove image not in the view
+
+ this.trigger_up.bind(this, 'wysiwyg_attachment', this.attachments);
+ },
+ /**
+ * Called when the carret focuses on another node (focus event, mouse event, or key arrow event)
+ * from Unbreakable
+ *
+ * @private
+ * @param {Node} node
+ */
+ _onFocusnode: function (node) {},
+ /**
+ * Do not override.
+ *
+ * @see _getRecordInfo
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {Object} ev.data.recordInfo
+ * @param {Function(recordInfo)} ev.data.callback
+ */
+ _onGetRecordInfo: function (ev) {
+ var data = this._getRecordInfo(ev.data);
+ data.attachmentIDs = _.pluck(this.attachments, 'id');
+ data.user_id = session.uid || session.user_id;
+ ev.data.callback(data);
+ },
+});
+
+//--------------------------------------------------------------------------
+// Public helper
+//--------------------------------------------------------------------------
+
+/**
+ * Load wysiwyg assets if needed.
+ *
+ * @see Wysiwyg.createReadyFunction
+ * @param {Widget} parent
+ * @returns {$.Promise}
+ */
+Wysiwyg.prepare = (function () {
+ var assetsLoaded = false;
+ var def;
+ return function prepare(parent) {
+ if (assetsLoaded) {
+ return $.when();
+ }
+ if (def) {
+ return def;
+ }
+ def = $.Deferred();
+ var timeout = setTimeout(function () {
+ throw _t("Can't load assets of the wysiwyg editor");
+ }, 10000);
+ var wysiwyg = new Wysiwyg(parent, {
+ recordInfo: {
+ context: {},
+ }
+ });
+ wysiwyg.attachTo($('<textarea>')).then(function () {
+ assetsLoaded = true;
+ clearTimeout(timeout);
+ wysiwyg.destroy();
+ def.resolve();
+ });
+ return def;
+ };
+})();
+/**
+ * @param {Node} node (editable or node inside)
+ * @returns {Object}
+ * @returns {Node} sc - start container
+ * @returns {Number} so - start offset
+ * @returns {Node} ec - end container
+ * @returns {Number} eo - end offset
+ */
+Wysiwyg.getRange = function (node) {
+ var range = $.summernote.range.create();
+ return range && {
+ sc: range.sc,
+ so: range.so,
+ ec: range.ec,
+ eo: range.eo,
+ };
+};
+/**
+ * @param {Node} startNode
+ * @param {Number} startOffset
+ * @param {Node} endNode
+ * @param {Number} endOffset
+ */
+Wysiwyg.setRange = function (startNode, startOffset, endNode, endOffset) {
+ $(startNode).focus();
+ if (endNode) {
+ $.summernote.range.create(startNode, startOffset, endNode, endOffset).select();
+ } else {
+ $.summernote.range.create(startNode, startOffset).select();
+ }
+ // trigger for Unbreakable
+ $(startNode.tagName ? startNode : startNode.parentNode).trigger('wysiwyg.range');
+};
+/**
+ * @param {Node} node - dom node
+ * @param {Object} [options]
+ * @param {Boolean} options.begin move the range to the beginning of the first node.
+ * @param {Boolean} options.end move the range to the end of the last node.
+ */
+Wysiwyg.setRangeFromNode = function (node, options) {
+ var last = node;
+ while (last.lastChild) {
+ last = last.lastChild;
+ }
+ var first = node;
+ while (first.firstChild) {
+ first = first.firstChild;
+ }
+
+ if (options && options.begin && !options.end) {
+ Wysiwyg.setRange(first, 0);
+ } else if (options && !options.begin && options.end) {
+ Wysiwyg.setRange(last, last.textContent.length);
+ } else {
+ Wysiwyg.setRange(first, 0, last, last.tagName ? last.childNodes.length : last.textContent.length);
+ }
+};
+
+//--------------------------------------------------------------------------
+// jQuery extensions
+//--------------------------------------------------------------------------
+
+$.extend($.expr[':'], {
+ o_editable: function (node, i, m) {
+ while (node) {
+ if (node.attributes) {
+ var className = _.isString(node.className) && node.className || '';
+ if (
+ className.indexOf('o_not_editable') !== -1 ||
+ (node.attributes.contenteditable &&
+ node.attributes.contenteditable.value !== 'true' &&
+ className.indexOf('o_fake_not_editable') === -1)
+ ) {
+ return false;
+ }
+ if (
+ className.indexOf('o_editable') !== -1 ||
+ (node.attributes.contenteditable &&
+ node.attributes.contenteditable.value === 'true' &&
+ className.indexOf('o_fake_editable') === -1)
+ ) {
+ return true;
+ }
+ }
+ node = node.parentNode;
+ }
+ return false;
+ },
+});
+
+return Wysiwyg;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js
new file mode 100644
index 00000000000..7bfe76e3d24
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js
@@ -0,0 +1,261 @@
+odoo.define('web_editor.wysiwyg.iframe', function (require) {
+'use strict';
+
+var Wysiwyg = require('web_editor.wysiwyg');
+var ajax = require('web.ajax');
+
+
+var _fnSummernoteMaster = $.fn.summernote;
+var _summernoteMaster = $.summernote;
+$.fn.summernote = function () {
+ var summernote = this[0].ownerDocument.defaultView._fnSummenoteSlave || _fnSummernoteMaster;
+ return summernote.apply(this, arguments);
+};
+
+/**
+ * Add option (inIframe) to load Wysiwyg in an iframe.
+ **/
+Wysiwyg.include({
+ /**
+ * Add options to load Wysiwyg in an iframe.
+ *
+ * @override
+ * @param {boolean} options.inIframe
+ **/
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ if (this.options.inIframe) {
+ if (!this.options.iframeCssAssets) {
+ this.options.iframeCssAssets = 'web_editor.wysiwyg_iframe_css_assets';
+ }
+ this._onUpdateIframeId = 'onLoad_' + this.id;
+ }
+ },
+ /**
+ * Load assets to inject into iframe.
+ *
+ * @override
+ **/
+ willStart: function () {
+ if (!this.options.inIframe) {
+ return this._super();
+ }
+ if (this.options.iframeCssAssets) {
+ this.defAsset = ajax.loadAsset(this.options.iframeCssAssets);
+ } else {
+ this.defAsset = $.when({cssLibs: [], cssContents: []});
+ }
+ this.$target = this.$el;
+ return this.defAsset
+ .then(this._loadIframe.bind(this))
+ .then(this._super.bind(this)).then(function () {
+ var _summernoteMaster = $.summernote;
+ var _summernoteSlave = this.$iframe[0].contentWindow._summernoteSlave;
+ _summernoteSlave.options = _.extend({}, _summernoteMaster.options, {modules: _summernoteSlave.options.modules});
+ this._enableBootstrapInIframe();
+ }.bind(this));
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (!this.options.inIframe) {
+ return this._super();
+ }
+ $(document.body).off('.' + this.id);
+
+ this.$target.insertBefore(this.$iframe);
+
+ delete window.top[this._onUpdateIframeId];
+ if (this.$iframeTarget) {
+ this.$iframeTarget.remove();
+ }
+ if (this.$iframe) {
+ this.$iframe.remove();
+ }
+ this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Change fullsreen feature.
+ *
+ * @override
+ * @returns {Object} modules list to load
+ */
+ _getPlugins: function () {
+ var self = this;
+ var plugins = this._super();
+ plugins.fullscreen = plugins.fullscreen.extend({
+ toggle: function () {
+ if (!self.$iframe) {
+ return this._super();
+ }
+ self.$iframe.toggleClass('o_fullscreen');
+ self.$iframe.contents().find('body').toggleClass('o_fullscreen');
+ },
+ isFullscreen: function () {
+ if (!self.$iframe) {
+ return this._super();
+ }
+ return self.$iframe.hasClass('o_fullscreen');
+ },
+ });
+ return plugins;
+ },
+ /**
+ * This method is called after the iframe is loaded with the editor. This is
+ * to activate the bootstrap features that out of the iframe would launch
+ * automatically when changing the dom.
+ *
+ * @private
+ */
+ _enableBootstrapInIframe: function () {
+ var body = this.$iframe[0].contentWindow.document.body;
+ var $toolbarButtons = this._summernote.layoutInfo.toolbar.find('[data-toggle="dropdown"]').dropdown({
+ boundary: body,
+ });
+
+ function hideDrowpdown() {
+ var $expended = $toolbarButtons.filter('[aria-expanded="true"]').parent();
+ $expended.children().removeAttr('aria-expanded').removeClass('show');
+ $expended.removeClass('show');
+ }
+ $(body).on('mouseup.' + this.id, hideDrowpdown);
+ $(document.body).on('click.' + this.id, hideDrowpdown);
+ },
+ /**
+ * Create iframe, inject css and create a link with the content,
+ * then inject the target inside.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _loadIframe: function () {
+ this.$iframe = $('<iframe class="wysiwyg_iframe">').css({
+ 'min-height': '400px',
+ width: '100%'
+ });
+ var avoidDoubleLoad = 0; // this bug only appears on some configurations.
+
+ // resolve deferred on load
+
+ var def = $.Deferred();
+ this.$iframe.data('load-def', def); // for unit test
+ window.top[this._onUpdateIframeId] = function (_avoidDoubleLoad) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg iframe double load detected');
+ return;
+ }
+ delete window.top[this._onUpdateIframeId];
+ var $iframeTarget = this.$iframe.contents().find('#iframe_target');
+ $iframeTarget.append(this.$target);
+ def.resolve();
+ }.bind(this);
+
+ // inject content in iframe
+
+ this.$iframe.on('load', function onLoad (ev) {
+ var _avoidDoubleLoad = ++avoidDoubleLoad;
+ this.defAsset.then(function (asset) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg immediate iframe double load detected');
+ return;
+ }
+ var cwindow = this.$iframe[0].contentWindow;
+ cwindow.document
+ .open("text/html", "replace")
+ .write(
+ '<head>' +
+ '<meta charset="utf-8">' +
+ '<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>\n' +
+ '<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>\n' +
+ _.map(asset.cssLibs, function (cssLib) {
+ return '<link type="text/css" rel="stylesheet" href="' + cssLib + '"/>';
+ }).join('\n') + '\n' +
+ _.map(asset.cssContents, function (cssContent) {
+ return '<style type="text/css">' + cssContent + '</style>';
+ }).join('\n') + '\n' +
+ _.map(asset.jsContents, function (jsContent) {
+ if (jsContent.indexOf('<inline asset>') !== -1) {
+ return '<script type="text/javascript">' + jsContent + '</script>';
+ }
+ }).join('\n') + '\n' +
+ '</head>\n' +
+ '<body class="o_in_iframe">\n' +
+ '<div id="iframe_target" style="height: calc(100vh - 6px);"></div>\n' +
+ '<script type="text/javascript">' +
+ 'window.$ = window.jQuery = window.top.jQuery;' +
+ 'var _summernoteMaster = $.summernote;' +
+ 'var _fnSummernoteMaster = $.fn.summernote;' +
+ 'delete $.summernote;' +
+ 'delete $.fn.summernote;' +
+ '</script>\n' +
+ '<script type="text/javascript" src="/web_editor/static/lib/summernote/summernote.js"></script>\n' +
+ '<script type="text/javascript">' +
+ 'window._summernoteSlave = $.summernote;' +
+ 'window._summernoteSlave.iframe = true;' +
+ 'window._summernoteSlave.lang = _summernoteMaster.lang;' +
+ 'window._fnSummenoteSlave = $.fn.summernote;' +
+ '$.summernote = _summernoteMaster;' +
+ '$.fn.summernote = _fnSummernoteMaster;' +
+ 'if (window.top.' + this._onUpdateIframeId + ') {' +
+ 'window.top.' + this._onUpdateIframeId + '(' + _avoidDoubleLoad + ')' +
+ '}' +
+ '</script>\n' +
+ '</body>');
+ }.bind(this));
+ }.bind(this));
+
+ this.$iframe.insertAfter(this.$target);
+
+ return def.promise();
+ },
+});
+
+//--------------------------------------------------------------------------
+// Public helper
+//--------------------------------------------------------------------------
+
+/**
+ * Get the current range from Summernote.
+ *
+ * @param {Node} [DOM]
+ * @returns {Object}
+ * @returns {Node} sc - start container
+ * @returns {Number} so - start offset
+ * @returns {Node} ec - end container
+ * @returns {Number} eo - end offset
+*/
+Wysiwyg.getRange = function (DOM) {
+ var summernote = (DOM.defaultView || DOM.ownerDocument.defaultView)._summernoteSlave || _summernoteMaster;
+ var range = summernote.range.create();
+ return range && {
+ sc: range.sc,
+ so: range.so,
+ ec: range.ec,
+ eo: range.eo,
+ };
+};
+/**
+ * @param {Node} sc - start container
+ * @param {Number} so - start offset
+ * @param {Node} ec - end container
+ * @param {Number} eo - end offset
+*/
+Wysiwyg.setRange = function (sc, so, ec, eo) {
+ var summernote = sc.ownerDocument.defaultView._summernoteSlave || _summernoteMaster;
+ $(sc).focus();
+ if (ec) {
+ summernote.range.create(sc, so, ec, eo).select();
+ } else {
+ summernote.range.create(sc, so).select();
+ }
+ // trigger for Unbreakable
+ $(sc.tagName ? sc : sc.parentNode).trigger('wysiwyg.range');
+};
+
+});
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js
index fe8876468b5..bcc3688a118 100644
--- a/addons/web_editor/static/src/js/editor/snippets.editor.js
+++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.editor.js
@@ -5,6 +5,7 @@ var core = require('web.core');
var dom = require('web.dom');
var Widget = require('web.Widget');
var options = require('web_editor.snippets.options');
+var Wysiwyg = require('web_editor.wysiwyg');
var _t = core._t;
@@ -24,6 +25,7 @@ var SnippetEditor = Widget.extend({
'click .oe_snippet_parent': '_onParentButtonClick',
'click .oe_snippet_clone': '_onCloneClick',
'click .oe_snippet_remove': '_onRemoveClick',
+ 'mousedown .oe_options [data-toggle="dropdown"]:first': '_onOpenCusomize',
},
custom_events: {
cover_update: '_onCoverUpdate',
@@ -35,9 +37,15 @@ var SnippetEditor = Widget.extend({
* @param {Widget} parent
* @param {Element} target
* @param templateOptions
+ * @param {jQuery} $editable
+ * @param {Object} options
*/
- init: function (parent, target, templateOptions) {
+ init: function (parent, target, templateOptions, $editable, options) {
this._super.apply(this, arguments);
+ this.options = options;
+ this.$editable = $editable;
+ this.ownerDocument = this.$editable[0].ownerDocument;
+ this.$body = $(this.ownerDocument.body);
this.$target = $(target);
this.$target.data('snippet-editor', this);
this.templateOptions = templateOptions;
@@ -59,13 +67,13 @@ var SnippetEditor = Widget.extend({
defs.push(this._initializeOptions());
// Initialize move/clone/remove buttons
- if (!this.$target.parent().is(':o_editable')) {
+ if (!this.options.isEditableNode(this.$target[0])) {
this.$el.find('.oe_snippet_move, .oe_snippet_clone, .oe_snippet_remove').remove();
} else {
this.dropped = false;
this.$el.draggable({
greedy: true,
- appendTo: 'body',
+ appendTo: this.$body,
cursor: 'move',
handle: '.oe_snippet_move',
cursorAt: {
@@ -76,7 +84,7 @@ var SnippetEditor = Widget.extend({
var $clone = $(this).clone().css({width: '24px', height: '24px', border: 0});
$clone.find('.oe_overlay_options >:not(:contains(.oe_snippet_move)), .o_handle').remove();
$clone.find(':not(.glyphicon)').css({position: 'absolute', top: 0, left: 0});
- $clone.appendTo('body').removeClass('d-none');
+ $clone.appendTo(self.$body).removeClass('d-none');
return $clone;
},
start: _.bind(self._onDragAndDropStart, self),
@@ -134,7 +142,7 @@ var SnippetEditor = Widget.extend({
top: offset.top,
});
this.$('.o_handles').css('height', this.$target.outerHeight());
- this.$el.toggleClass('o_top_cover', offset.top < 15);
+ this.$el.toggleClass('o_top_cover', offset.top < this.$editable.offset().top);
},
/**
* Removes the associated snippet from the DOM and destroys the associated
@@ -159,7 +167,6 @@ var SnippetEditor = Widget.extend({
var node = $parent[0];
if (node && node.firstChild) {
- $.summernote.core.dom.removeSpace(node, node.firstChild, 0, node.lastChild, 1);
if (!node.firstChild.tagName && node.firstChild.textContent === ' ') {
node.removeChild(node.firstChild);
}
@@ -181,11 +188,12 @@ var SnippetEditor = Widget.extend({
}
// clean editor if they are image or table in deleted content
- $('.note-control-selection').hide();
- $('.o_table_handler').remove();
+ this.$body.find('.note-control-selection').hide();
+ this.$body.find('.o_table_handler').remove();
this.trigger_up('snippet_removed');
this.destroy();
+ $parent.trigger('content_changed');
},
/**
* Displays/Hides the editor overlay and notifies the associated snippet
@@ -195,6 +203,9 @@ var SnippetEditor = Widget.extend({
* @param {boolean} focus - true to display, false to hide
*/
toggleFocus: function (focus) {
+ if (!this.$el) {
+ return;
+ }
var do_action = (focus ? _do_action_focus : _do_action_blur);
// Attach own and parent options on the current overlay
@@ -222,6 +233,10 @@ var SnippetEditor = Widget.extend({
this.cover();
this.$el.toggleClass('oe_active', !!focus);
+ if (focus) {
+ this.trigger_up('snippet_focused');
+ }
+
function _do_action_focus(style, $dest) {
style.$el.insertAfter($dest);
style.onFocus();
@@ -282,7 +297,8 @@ var SnippetEditor = Widget.extend({
self,
val.base_target ? self.$target.find(val.base_target).eq(0) : self.$target,
self.$el,
- val.data
+ val.data,
+ self.options
);
self.styles[optionName || _.uniqueId('option')] = option;
option.__order = i++;
@@ -324,10 +340,11 @@ var SnippetEditor = Widget.extend({
*/
_onCloneClick: function (ev) {
ev.preventDefault();
+ this.trigger_up('cover_will_change');
+
var $clone = this.$target.clone(false);
this.trigger_up('request_history_undo_record', {$target: this.$target});
-
this.$target.after($clone);
this.trigger_up('call_for_each_child_snippet', {
$snippet: $clone,
@@ -340,6 +357,7 @@ var SnippetEditor = Widget.extend({
},
});
this.trigger_up('snippet_cloned', {$target: $clone});
+ $clone.trigger('content_changed');
},
/**
* Called when the overlay dimensions/positions should be recomputed.
@@ -347,6 +365,7 @@ var SnippetEditor = Widget.extend({
* @private
*/
_onCoverUpdate: function () {
+ this.trigger_up('cover_will_change');
this.cover();
},
/**
@@ -382,11 +401,11 @@ var SnippetEditor = Widget.extend({
$selectorChildren: $selectorChildren,
});
- $('body').addClass('move-important');
+ this.$body.addClass('move-important');
- $('.oe_drop_zone').droppable({
+ this.$editable.find('.oe_drop_zone').droppable({
over: function () {
- $('.oe_drop_zone.hide').removeClass('hide');
+ self.$editable.find('.oe_drop_zone.hide').removeClass('hide');
$(this).addClass('hide').first().after(self.$target);
self.dropped = true;
},
@@ -396,6 +415,8 @@ var SnippetEditor = Widget.extend({
self.dropped = false;
},
});
+
+ this.trigger_up('cover_will_change');
},
/**
* Called when the snippet is dropped after being dragged thanks to the
@@ -404,24 +425,23 @@ var SnippetEditor = Widget.extend({
* @private
*/
_onDragAndDropStop: function () {
- var self = this;
-
- $('.oe_drop_zone').droppable('destroy').remove();
+ this.$editable.find('.oe_drop_zone').droppable('destroy').remove();
var prev = this.$target.first()[0].previousSibling;
var next = this.$target.last()[0].nextSibling;
var $parent = this.$target.parent();
- var $clone = $('.oe_drop_clone');
+ var $clone = this.$editable.find('.oe_drop_clone');
if (prev === $clone[0]) {
prev = $clone[0].previousSibling;
} else if (next === $clone[0]) {
next = $clone[0].nextSibling;
}
$clone.after(this.$target);
+ var $from = $clone.parent();
this.$el.removeClass('d-none');
- $('body').removeClass('move-important');
+ this.$body.removeClass('move-important');
$clone.remove();
if (this.dropped) {
@@ -438,10 +458,13 @@ var SnippetEditor = Widget.extend({
for (var i in this.styles) {
this.styles[i].onMove();
}
+
+ this.$target.trigger('content_changed');
+ $from.trigger('content_changed');
}
- self.trigger_up('drag_and_drop_stop', {
- $snippet: self.$target,
+ this.trigger_up('drag_and_drop_stop', {
+ $snippet: this.$target,
});
},
/**
@@ -452,6 +475,7 @@ var SnippetEditor = Widget.extend({
* @param {OdooEvent} ev
*/
_onOptionUpdate: function (ev) {
+ this.trigger_up('cover_will_change');
// If multiple option names are given, we suppose it should not be
// propagated to parent editor
if (ev.data.optionNames) {
@@ -475,6 +499,16 @@ var SnippetEditor = Widget.extend({
}
},
/**
+ * Called when the user opens the customize menu.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOpenCusomize: function (ev) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ },
+
+ /**
* Called when the 'parent' button is clicked.
*
* @private
@@ -494,6 +528,7 @@ var SnippetEditor = Widget.extend({
*/
_onRemoveClick: function (ev) {
ev.preventDefault();
+ this.trigger_up('cover_will_change');
this.trigger_up('request_history_undo_record', {$target: this.$target});
this.removeSnippet();
},
@@ -504,6 +539,7 @@ var SnippetEditor = Widget.extend({
*/
var SnippetsMenu = Widget.extend({
id: 'oe_snippets',
+ cacheSnippetTemplate: {},
activeSnippets: [],
custom_events: {
activate_insertion_zones: '_onActivateInsertionZones',
@@ -513,15 +549,34 @@ var SnippetsMenu = Widget.extend({
go_to_parent: '_onGoToParent',
remove_snippet: '_onRemoveSnippet',
snippet_removed: '_onSnippetRemoved',
+ reload_snippet_dropzones: '_disableUndroppableSnippets',
},
/**
+ * @param {Widget} parent
+ * @param {Object} [options]
+ * @param {string} [options.snippets]
+ * URL of the snippets template. This URL might have been set
+ * in the global 'snippets' variable, otherwise this function
+ * assigns a default one.
+ * default: 'web_editor.snippets'
+ *
* @constructor
*/
- init: function (parent, $editable) {
+ init: function (parent, options) {
this._super.apply(this, arguments);
+ options = options || {};
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
- this.$editable = $editable;
+ this.options = options;
+ if (!this.options.snippets) {
+ this.options.snippets = 'web_editor.snippets';
+ }
this.$activeSnippet = false;
this.snippetEditors = [];
},
@@ -531,12 +586,14 @@ var SnippetsMenu = Widget.extend({
start: function () {
var self = this;
var defs = [this._super.apply(this, arguments)];
- var $document = $(document);
- var $window = $(window);
+ this.ownerDocument = this.$el[0].ownerDocument;
+ this.$document = $(this.ownerDocument);
+ this.window = this.ownerDocument.defaultView;
+ this.$window = $(this.window);
// Fetch snippet templates and compute it
- var url = this._getSnippetURL();
- defs.push(this._rpc({route: url}).then(function (html) {
+
+ defs.push(this.loadSnippets().then(function (html) {
return self._computeSnippetTemplates(html);
}));
@@ -547,7 +604,7 @@ var SnippetsMenu = Widget.extend({
// Active snippet editor on click in the page
var lastClickedElement;
- $document.on('click.snippets_menu', '*', function (ev) {
+ this.$document.on('click.snippets_menu', '*', function (ev) {
var srcElement = ev.srcElement || (ev.originalEvent && (ev.originalEvent.originalTarget || ev.originalEvent.target) || ev.target);
if (lastClickedElement === srcElement || !srcElement) {
return;
@@ -567,27 +624,21 @@ var SnippetsMenu = Widget.extend({
core.bus.on('deactivate_snippet', this, this._onDeactivateSnippet);
core.bus.on('snippet_editor_clean_for_save', this, this._onCleanForSaveDemand);
- // Some summernote customization
- var _isNotBreakable = $.summernote.core.dom.isNotBreakable;
- $.summernote.core.dom.isNotBreakable = function (node) {
- return _isNotBreakable(node) || $(node).is('div') || globalSelector.is($(node));
- };
-
// Adapt overlay covering when the window is resized / content changes
var debouncedCoverUpdate = _.debounce(function () {
- self._updateCurrentSnippetEditorOverlay();
+ self.updateCurrentSnippetEditorOverlay();
}, 200);
- $window.on('resize.snippets_menu', debouncedCoverUpdate);
- $window.on('content_changed.snippets_menu', debouncedCoverUpdate);
+ this.$window.on('resize.snippets_menu', debouncedCoverUpdate);
+ this.$window.on('content_changed.snippets_menu', debouncedCoverUpdate);
// On keydown add a class on the active overlay to hide it and show it
// again when the mouse moves
- $document.on('keydown.snippets_menu', function () {
+ this.$document.on('keydown.snippets_menu', function () {
if (self.$activeSnippet && self.$activeSnippet.data('snippet-editor')) {
self.$activeSnippet.data('snippet-editor').$el.addClass('o_keypress');
}
});
- $document.on('mousemove.snippets_menu', function () {
+ this.$document.on('mousemove.snippets_menu, mousedown.snippets_menu', function () {
if (self.$activeSnippet && self.$activeSnippet.data('snippet-editor')) {
self.$activeSnippet.data('snippet-editor').$el.removeClass('o_keypress');
}
@@ -595,12 +646,12 @@ var SnippetsMenu = Widget.extend({
// Auto-selects text elements with a specific class and remove this
// on text changes
- $document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) {
+ this.$document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) {
$(ev.target).selectContent();
});
- $document.on('keyup.snippets_menu', function () {
- var r = $.summernote.core.range.create();
- $(r && r.sc).closest('.o_default_snippet_text').removeClass('o_default_snippet_text');
+ this.$document.on('keyup.snippets_menu', function () {
+ var range = Wysiwyg.getRange(this);
+ $(range && range.sc).closest('.o_default_snippet_text').removeClass('o_default_snippet_text');
});
return $.when.apply($, defs).then(function () {
@@ -608,7 +659,7 @@ var SnippetsMenu = Widget.extend({
// menu will take part of the screen width (delayed because of
// animation). (TODO wait for real animation end)
setTimeout(function () {
- $window.trigger('resize');
+ self.$window.trigger('resize');
}, 1000);
});
},
@@ -617,11 +668,16 @@ var SnippetsMenu = Widget.extend({
*/
destroy: function () {
this._super.apply(this, arguments);
- this.$snippetEditorArea.remove();
- $(window).off('.snippets_menu');
- $(document).off('.snippets_menu');
+ if (this.$window) {
+ this.$snippetEditorArea.remove();
+ this.$window.off('.snippets_menu');
+ this.$document.off('.snippets_menu');
+ }
core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet);
core.bus.off('snippet_editor_clean_for_save', this, this._onCleanForSaveDemand);
+ if (this._defLoadSnippets.state() === "pending") {
+ delete this.cacheSnippetTemplate[this.options.snippets];
+ }
},
//--------------------------------------------------------------------------
@@ -634,16 +690,68 @@ var SnippetsMenu = Widget.extend({
* - Remove the 'contentEditable' attributes
*/
cleanForSave: function () {
+ this._activateSnippet(false);
this.trigger_up('ready_to_clean_for_save');
this._destroyEditors();
- this.$editable.find('[contentEditable]')
+ this.getEditableArea().find('[contentEditable]')
.removeAttr('contentEditable')
.removeProp('contentEditable');
- this.$editable.find('.o_we_selected_image')
+ this.getEditableArea().find('.o_we_selected_image')
.removeClass('o_we_selected_image');
},
+ /**
+ * Load snippets.
+ */
+ loadSnippets: function () {
+ if (this.cacheSnippetTemplate[this.options.snippets]) {
+ this._defLoadSnippets = this.cacheSnippetTemplate[this.options.snippets];
+ return this._defLoadSnippets;
+ }
+ this._defLoadSnippets = this._rpc({
+ model: 'ir.ui.view',
+ method: 'render_template',
+ args: [this.options.snippets, {}],
+ kwargs: {
+ context: this.options.context,
+ },
+ });
+ this.cacheSnippetTemplate[this.options.snippets] = this._defLoadSnippets;
+ },
+ /**
+ * Sets the instance variables $editor, $body and selectorEditableArea.
+ *
+ * @param {JQuery} $editor
+ * @param {String} selectorEditableArea
+ */
+ setSelectorEditableArea: function ($editor, selectorEditableArea) {
+ this.selectorEditableArea = selectorEditableArea;
+ this.$editor = $editor;
+ this.$body = $editor.closest('body');
+ },
+ /**
+ * Get the editable area.
+ *
+ * @returns {JQuery}
+ */
+ getEditableArea: function () {
+ return this.$editor.find(this.selectorEditableArea);
+ },
+ /**
+ * Updates the cover dimensions of the current snippet editor.
+ */
+ updateCurrentSnippetEditorOverlay: function () {
+ if (this.$activeSnippet && this.$activeSnippet.data('snippet-editor')) {
+ this.$activeSnippet.data('snippet-editor').cover();
+ }
+ this.snippetEditors = _.filter(this.snippetEditors, function (snippetEditor) {
+ if (snippetEditor.$target.closest('body').length) {
+ return true;
+ }
+ snippetEditor.destroy();
+ });
+ },
//--------------------------------------------------------------------------
// Private
@@ -661,6 +769,7 @@ var SnippetsMenu = Widget.extend({
* child
*/
_activateInsertionZones: function ($selectorSiblings, $selectorChildren) {
+ var self = this;
var zone_template = $('<div/>', {
class: 'oe_drop_zone oe_insert',
});
@@ -668,8 +777,8 @@ var SnippetsMenu = Widget.extend({
if ($selectorChildren) {
$selectorChildren.each(function () {
var $zone = $(this);
- var css = window.getComputedStyle(this);
- var parentCss = window.getComputedStyle($zone.parent()[0]);
+ var css = self.window.getComputedStyle(this);
+ var parentCss = self.window.getComputedStyle($zone.parent()[0]);
var float = css.float || css.cssFloat;
var parentDisplay = parentCss.display;
var parentFlex = parentCss.flexDirection;
@@ -680,7 +789,7 @@ var SnippetsMenu = Widget.extend({
var test = !!(node && ((!node.tagName && node.textContent.match(/\S/)) || node.tagName === 'BR'));
if (test) {
$drop.addClass('oe_vertical').css({
- height: parseInt(window.getComputedStyle($zone[0]).lineHeight),
+ height: parseInt(self.window.getComputedStyle($zone[0]).lineHeight),
float: 'none',
display: 'inline-block',
});
@@ -695,7 +804,7 @@ var SnippetsMenu = Widget.extend({
test = !!(node && ((!node.tagName && node.textContent.match(/\S/)) || node.tagName === 'BR'));
if (test) {
$drop.addClass('oe_vertical').css({
- height: parseInt(window.getComputedStyle($zone[0]).lineHeight),
+ height: parseInt(self.window.getComputedStyle($zone[0]).lineHeight),
float: 'none',
display: 'inline-block'
});
@@ -715,8 +824,8 @@ var SnippetsMenu = Widget.extend({
$selectorSiblings.filter(':not(.oe_drop_zone):not(.oe_drop_clone)').each(function () {
var $zone = $(this);
var $drop;
- var css = window.getComputedStyle(this);
- var parentCss = window.getComputedStyle($zone.parent()[0]);
+ var css = self.window.getComputedStyle(this);
+ var parentCss = self.window.getComputedStyle($zone.parent()[0]);
var float = css.float || css.cssFloat;
var parentDisplay = parentCss.display;
var parentFlex = parentCss.flexDirection;
@@ -742,14 +851,14 @@ var SnippetsMenu = Widget.extend({
var $zones;
do {
count = 0;
- $zones = this.$editable.find('.oe_drop_zone > .oe_drop_zone').remove(); // no recursive zones
+ $zones = this.getEditableArea().find('.oe_drop_zone > .oe_drop_zone').remove(); // no recursive zones
count += $zones.length;
$zones.remove();
} while (count > 0);
// Cleaning consecutive zone and up zones placed between floating or
// inline elements. We do not like these kind of zones.
- $zones = this.$editable.find('.oe_drop_zone:not(.oe_vertical)');
+ $zones = this.getEditableArea().find('.oe_drop_zone:not(.oe_vertical)');
$zones.each(function () {
var zone = $(this);
var prev = zone.prev();
@@ -805,6 +914,7 @@ var SnippetsMenu = Widget.extend({
}
if ($snippet && $snippet.length) {
var self = this;
+ this.trigger_up('activate_snippet', $snippet);
return this._createSnippetEditor($snippet).then(function (editor) {
self.$activeSnippet = $snippet;
editor.toggleFocus(true);
@@ -822,16 +932,6 @@ var SnippetsMenu = Widget.extend({
});
},
/**
- * Updates the cover dimensions of the current snippet editor.
- *
- * @private
- */
- _updateCurrentSnippetEditorOverlay: function () {
- if (this.$activeSnippet && this.$activeSnippet.data('snippet-editor')) {
- this.$activeSnippet.data('snippet-editor').cover();
- }
- },
- /**
* Calls a given callback 'on' the given snippet and all its child ones if
* any (DOM element with options).
*
@@ -910,7 +1010,7 @@ var SnippetsMenu = Widget.extend({
selectorConditions += ':has(' + target + ')';
}
if (!noCheck) {
- selectorConditions = ':o_editable' + selectorConditions;
+ selectorConditions = (this.options.addDropSelector || '') + selectorConditions;
}
// (Re)join the subselectors
@@ -933,7 +1033,7 @@ var SnippetsMenu = Widget.extend({
};
} else {
functions.closest = function ($from, parentNode) {
- var parents = self.$editable.get();
+ var parents = self.getEditableArea().get();
return $from.closest(selector, parentNode).filter(function () {
var node = this;
while (node.parentNode) {
@@ -946,9 +1046,9 @@ var SnippetsMenu = Widget.extend({
});
};
functions.all = isChildren ? function ($from) {
- return dom.cssFind($from || self.$editable, selector);
+ return dom.cssFind($from || self.getEditableArea(), selector);
} : function ($from) {
- $from = $from || self.$editable;
+ $from = $from || self.getEditableArea();
return $from.filter(selector).add(dom.cssFind($from, selector));
};
}
@@ -1075,7 +1175,6 @@ var SnippetsMenu = Widget.extend({
if (!this.$snippets.length) {
this.$el.detach();
}
- $('body').toggleClass('editor_has_snippets', this.$snippets.length > 0);
// Register the text nodes that needs to be auto-selected on click
this._registerDefaultTexts();
@@ -1123,7 +1222,7 @@ var SnippetsMenu = Widget.extend({
}
return $.when(def).then(function (parentEditor) {
- snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions);
+ snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions, self.getEditableArea(), self.options);
self.snippetEditors.push(snippetEditor);
return snippetEditor.appendTo(self.$snippetEditorArea);
}).then(function () {
@@ -1159,17 +1258,6 @@ var SnippetsMenu = Widget.extend({
});
},
/**
- * Returns the URL where to find the snippets template. This URL might have
- * been set in the global 'snippetsURL' variable, otherwise this function
- * returns a default one.
- *
- * @private
- * @returns {string}
- */
- _getSnippetURL: function () {
- return odoo.snippetsURL || '/web_editor/snippets';
- },
- /**
* Make given snippets be draggable/droppable thanks to their thumbnail.
*
* @private
@@ -1185,8 +1273,7 @@ var SnippetsMenu = Widget.extend({
$snippets.draggable({
greedy: true,
helper: 'clone',
- zIndex: '1000',
- appendTo: 'body',
+ appendTo: this.$body,
cursor: 'move',
handle: '.oe_snippet_thumbnail',
distance: 30,
@@ -1204,12 +1291,10 @@ var SnippetsMenu = Widget.extend({
for (var k in temp) {
if ($base_body.is(temp[k].base_selector) && !$base_body.is(temp[k].base_exclude)) {
if (temp[k]['drop-near']) {
- if (!$selectorSiblings) $selectorSiblings = temp[k]['drop-near'].all();
- else $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all());
+ $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all());
}
if (temp[k]['drop-in']) {
- if (!$selectorChildren) $selectorChildren = temp[k]['drop-in'].all();
- else $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all());
+ $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all());
}
}
}
@@ -1224,7 +1309,7 @@ var SnippetsMenu = Widget.extend({
self._activateSnippet(false);
self._activateInsertionZones($selectorSiblings, $selectorChildren);
- $('.oe_drop_zone').droppable({
+ self.getEditableArea().find('.oe_drop_zone').droppable({
over: function () {
if (!dropped) {
dropped = true;
@@ -1240,6 +1325,8 @@ var SnippetsMenu = Widget.extend({
}
},
});
+
+ self.trigger_up('cover_will_change');
},
stop: function (ev, ui) {
$toInsert.removeClass('oe_snippet_body');
@@ -1252,7 +1339,7 @@ var SnippetsMenu = Widget.extend({
}
}
- self.$editable.find('.oe_drop_zone').droppable('destroy').remove();
+ self.getEditableArea().find('.oe_drop_zone').droppable('destroy').remove();
if (dropped) {
var prev = $toInsert.first()[0].previousSibling;
@@ -1273,8 +1360,6 @@ var SnippetsMenu = Widget.extend({
$parent.prepend($toInsert);
}
- $toInsert.closest('.o_editable').trigger('content_changed');
-
var $target = $toInsert;
_.defer(function () {
@@ -1282,12 +1367,12 @@ var SnippetsMenu = Widget.extend({
self._disableUndroppableSnippets();
self._callForEachChildSnippet($target, function (editor, $snippet) {
- _.defer(function () {
- editor.buildSnippet();
- });
+ editor.buildSnippet();
}).then(function () {
- $target.closest('.o_editable').trigger('content_changed');
- self._activateSnippet($target);
+ $target.trigger('content_changed');
+ if ($target.closest('body').length) { // can be destroyed (eg in test)
+ self._activateSnippet($target);
+ }
});
});
} else {
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js
index 97d3777b95f..802311f09d0 100644
--- a/addons/web_editor/static/src/js/editor/snippets.options.js
+++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js
@@ -4,8 +4,9 @@ odoo.define('web_editor.snippets.options', function (require) {
var core = require('web.core');
var Dialog = require('web.Dialog');
var Widget = require('web.Widget');
-var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
-var weWidgets = require('web_editor.widget');
+var weWidgets = require('wysiwyg.widgets');
+var FontPlugin = require('web_editor.wysiwyg.plugin.font');
+var ColorpickerDialog = require('wysiwyg.widgets.ColorpickerDialog');
var qweb = core.qweb;
var _t = core._t;
@@ -18,10 +19,12 @@ var _t = core._t;
var SnippetOption = Widget.extend({
events: {
'mouseenter': '_onLinkEnter',
- 'mouseenter .dropdown-item': '_onLinkEnter',
+ 'mouseenter a': '_onLinkEnter',
'click': '_onLinkClick',
+ 'click a': '_onLinkClick',
'mouseleave': '_onMouseleave',
- 'mouseleave .dropdown-item': '_onMouseleave',
+ 'mouseleave a': '_onMouseleave',
+ 'mouseleave .dropdown-menu': '_onMouseleave',
},
/**
* When editing a snippet, its options are shown alongside the ones of its
@@ -38,9 +41,11 @@ var SnippetOption = Widget.extend({
*
* @constructor
*/
- init: function (parent, $target, $overlay, data) {
+ init: function (parent, $target, $overlay, data, options) {
this._super.apply(this, arguments);
+ this.options = options;
this.$target = $target;
+ this.ownerDocument = this.$target[0].ownerDocument;
this.$overlay = $overlay;
this.data = data;
this.__methodNames = [];
@@ -240,6 +245,7 @@ var SnippetOption = Widget.extend({
if (!previewMode) {
this._reset();
this.trigger_up('request_history_undo_record', {$target: this.$target});
+ this.$target.trigger('content_changed');
}
// Search for methods (data-...) (i.e. data-toggle-class) on the
@@ -353,7 +359,7 @@ var SnippetOption = Widget.extend({
*/
_onLinkClick: function (ev) {
var $opt = $(ev.target).closest('.dropdown-item');
- if (!$opt.length || !$opt.is(':hasData')) {
+ if (ev.isDefaultPrevented() || !$opt.length || !$opt.is(':hasData')) {
return;
}
@@ -440,7 +446,7 @@ registry.sizing = SnippetOption.extend({
var regClass = new RegExp('\\s*' + resize[0][begin].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g');
var cursor = $handle.css('cursor') + '-important';
- var $body = $(document.body);
+ var $body = $(this.ownerDocument.body);
$body.addClass(cursor);
var xy = ev['page' + XY];
@@ -647,12 +653,19 @@ registry.colorpicker = SnippetOption.extend({
}
if (!this.$el.find('.colorpicker').length) {
- // TODO remove old UI's code that survived
- var $pt = $(qweb.render('web_editor.snippet.option.colorpicker'));
- var $clpicker = $(qweb.render('web_editor.colorpicker'));
+ // Add common colors to palettes if not excluded
+ var fontPlugin = new FontPlugin({
+ layoutInfo: {
+ editable: $('<div/>'),
+ editingArea: $('<div/>'),
+ },
+ options: {},
+ });
- _.each($clpicker.find('.o_colorpicker_section'), function (elem) {
- $(elem).prepend("<div class='text-muted mt8'>" + elem.dataset.display + "</div>");
+ var $clpicker = fontPlugin.createPalette('backColor').find('.note-color-palette'); // don't use custom color
+ $clpicker.find('.note-color-reset').remove();
+ $clpicker.find('h6').each(function () {
+ $(this).replaceWith($('<div class="mt8"/>').text($(this).text()));
});
// Retrieve excluded palettes list
@@ -662,7 +675,7 @@ registry.colorpicker = SnippetOption.extend({
}
// Apply a custom title if specified
if (this.data.paletteTitle) {
- $pt.find('.note-palette-title').text(this.data.paletteTitle);
+ $clpicker.find('.note-palette-title').text(this.data.paletteTitle);
}
// Remove excluded palettes
@@ -670,39 +683,29 @@ registry.colorpicker = SnippetOption.extend({
$clpicker.find('[data-name="' + exc + '"]').remove();
});
- // Add common colors to palettes if not excluded
- if (!('common' in excluded)) {
- var $commonColorSection = $clpicker.find('[data-name="common"]');
- _.each(summernoteCustomColors, function (colorRow, i) {
- var $div = $('<div/>', {class: 'clearfix'}).appendTo($commonColorSection);
- if (i === 0) {
- // Ignore the summernote gray palette and use ours
- return;
- }
- _.each(colorRow, function (color) {
- $div.append('<button class="o_custom_color" style="background-color: ' + color + '" />');
- });
- });
- }
-
+ var $pt = $(qweb.render('web_editor.snippet.option.colorpicker'));
$pt.find('.o_colorpicker_section_tabs').append($clpicker);
this.$el.find('.dropdown-menu').append($pt);
}
+ var bgColor = ColorpickerDialog.formatColor(self.$target.css('background-color'));
var classes = [];
this.$el.find('.colorpicker button').each(function () {
var $color = $(this);
var color = $color.data('color');
- if (!color) {
- return;
- }
-
- $color.addClass('bg-' + color);
- var className = self.colorPrefix + color;
- if (self.$target.hasClass(className)) {
- $color.addClass('selected');
+ if (color) {
+ $color.addClass('bg-' + color);
+ var className = self.colorPrefix + color;
+ if (self.$target.hasClass(className)) {
+ $color.addClass('selected');
+ }
+ classes.push(className);
+ } else {
+ color = $color.data('value');
+ if (bgColor === ColorpickerDialog.formatColor(color)) {
+ $color.addClass('selected');
+ }
}
- classes.push(className);
});
this.classes = classes.join(' ');
@@ -736,6 +739,9 @@ registry.colorpicker = SnippetOption.extend({
var color = $(ev.currentTarget).data('color');
if (color) {
this.$target.addClass(this.colorPrefix + color);
+ } else if ($(ev.currentTarget).data('value')) {
+ color = $(ev.currentTarget).data('value');
+ this.$target.css('background-color', color);
} else if ($(ev.target).hasClass('o_custom_color')) {
this.$target
.removeClass(this.classes)
@@ -828,7 +834,7 @@ registry.background = SnippetOption.extend({
*/
chooseImage: function (previewMode, value, $opt) {
// Put fake image in the DOM, edit it and use it as background-image
- var $image = $('<img/>', {class: 'd-none', src: value}).appendTo(this.$target);
+ var $image = $('<img/>', {class: 'd-none', src: value === 'true' ? '' : value}).appendTo(this.$target);
var $editable = this.$target.closest('.o_editable');
var _editor = new weWidgets.MediaDialog(this, {
@@ -836,10 +842,11 @@ registry.background = SnippetOption.extend({
firstFilters: ['background'],
res_model: $editable.data('oe-model'),
res_id: $editable.data('oe-id'),
- }, null, $image[0]).open();
+ }, $image[0]).open();
_editor.on('save', this, function () {
this._setCustomBackground($image.attr('src'));
+ this.$target.trigger('content_changed');
});
_editor.on('closed', this, function () {
$image.remove();
@@ -894,7 +901,8 @@ registry.background = SnippetOption.extend({
if (value === undefined) {
value = this.$target.css('background-image');
}
- return value.replace(/url\(['"]*|['"]*\)|^none$/g, '');
+ var srcValueWrapper = /url\(['"]*|['"]*\)|^none$/g;
+ return value && value.replace(srcValueWrapper, '') || '';
},
/**
* @override
@@ -1159,6 +1167,11 @@ registry.many2one = SnippetOption.extend({
*/
start: function () {
var self = this;
+ this.trigger_up('getRecordInfo', _.extend(this.options, {
+ callback: function (recordInfo) {
+ _.defaults(self.options, recordInfo);
+ },
+ }));
this.Model = this.$target.data('oe-many2one-model');
this.ID = +this.$target.data('oe-many2one-id');
@@ -1191,6 +1204,7 @@ registry.many2one = SnippetOption.extend({
this.$search.find('input')
.focus()
.on('keyup', function (e) {
+ self.$overlay.removeClass('o_keypress');
self._findExisting($(this).val());
});
@@ -1257,6 +1271,7 @@ registry.many2one = SnippetOption.extend({
kwargs: {
order: [{name: 'name', asc: false}],
limit: 5,
+ context: this.options.context,
},
}).then(function (result) {
self.$search.siblings().remove();
@@ -1295,6 +1310,7 @@ registry.many2one = SnippetOption.extend({
args: [[self.ID]],
kwargs: {
options: options,
+ context: self.options.context,
},
}).then(function (html) {
$node.html(html);
diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js b/addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js
new file mode 100644
index 00000000000..d1dc29ca7b1
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg_snippets/wysiwyg_snippets.js
@@ -0,0 +1,260 @@
+odoo.define('web_editor.wysiwyg.snippets', function (require) {
+'use strict';
+
+var Wysiwyg = require('web_editor.wysiwyg');
+var snippetsEditor = require('web_editor.snippet.editor');
+
+Wysiwyg.include({
+ events: _.extend({}, Wysiwyg.prototype.events, {
+ 'content_changed .o_editable': '_onContentChange',
+ 'content_changed .note-editable': '_onContentChange',
+ }),
+ custom_events: _.extend({}, Wysiwyg.prototype.custom_events, {
+ request_history_undo_record: '_onHistoryUndoRecordRequest',
+ cover_will_change: '_onCoverWillChange',
+ snippet_cloned: '_onSnippetCloned',
+ snippet_dropped: '_onSnippetDropped',
+ snippet_focused: '_onSnippetFocused',
+ }),
+
+ selectorEditableArea: '.note-editable',
+
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ options = _.clone(this.options);
+ if (!options.snippets) {
+ return;
+ }
+ if (options.snippets === true) {
+ options.snippets = 'web_editor.snippets';
+ }
+ options.isUnbreakableNode = this.isUnbreakableNode.bind(this);
+ options.isEditableNode = this.isEditableNode.bind(this);
+ this.snippets = new snippetsEditor.Class(this, options);
+ },
+ /**
+ * Preload snippets.
+ *
+ * @override
+ **/
+ willStart: function () {
+ if (this.snippets) {
+ this.snippets.loadSnippets(); // don't use the deferred
+ }
+ return this._super();
+ },
+ /**
+ * Add options (snippets) to load snippet building block
+ * snippets can by url begin with '/' or an view xml_id.
+ *
+ * @override
+ * @param {string} [options.snippets]
+ */
+ start: function () {
+ var self = this;
+ return this._super().then(function () {
+ if (!self.snippets) {
+ return;
+ }
+ var $editable = $(self._summernote.layoutInfo.editable);
+ self.snippets.setSelectorEditableArea(self.$el, self.selectorEditableArea);
+ self.snippets.insertBefore(self.$el).then(function () {
+ self.$el.before(self.snippets.$el);
+ var $wrap = $('<div class="o_wrap_editable_snippets"/>');
+ $wrap.on('scroll', function (event) {
+ self._summernote.triggerEvent('scroll', event);
+ });
+ var $contents = self.snippets.$el.siblings('#oe_manipulators')
+ .addClass('o_wysiwyg_to_remove').attr('contentEditable', false);
+ $wrap.append($contents);
+ $editable.before($wrap);
+ $wrap.append($editable);
+ setTimeout(function () { // add a set timeout for the transition
+ self.snippets.$el.addClass('o_loaded');
+ self.$el.addClass('o_snippets_loaded');
+ self.trigger_up('snippets_loaded', self.snippets.$el);
+ });
+ });
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ isUnbreakableNode: function (node) {
+ if (!this.snippets) {
+ return this._super(node);
+ }
+ return this._super(node) || node.tagName === 'DIV' || snippetsEditor.globalSelector.is($(node));
+ },
+ /**
+ * @override
+ */
+ isEditableNode: function (node) {
+ if (!this._super(node)) {
+ return false;
+ }
+ if (this.snippets && node.tagName === 'DIV' && !node.contentEditable) {
+ if ($(node).parent('.row').length) {
+ return true;
+ }
+ if (!_.find(node.childNodes, function (node) {
+ var char = this.context.invoke('HelperPlugin.getRegex', 'char');
+ return node.tagName !== 'DIV' && (node.tagName || node.textContent.match(char));
+ })) {
+ return false;
+ }
+ }
+ return true;
+ },
+ /**
+ * @override
+ */
+ save: function () {
+ if (!this.snippets) {
+ return this._super();
+ }
+ var defs = [];
+ this.trigger_up('ready_to_save', {defs: defs});
+ return $.when.apply($, defs)
+ .then(this.snippets.cleanForSave.bind(this.snippets))
+ .then(this._super.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onContentChange: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (this.snippets) {
+ this.addHistoryStep();
+ }
+ this._onChange();
+ },
+ /**
+ * @private
+ * @override
+ */
+ _onChange: function () {
+ if (this.snippets) {
+ this.snippets.updateCurrentSnippetEditorOverlay();
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Called when an element askes to record an history undo -> records it.
+ *
+ * @private
+ */
+ _onHistoryUndoRecordRequest: function () {
+ this.addHistoryStep();
+ },
+ /**
+ * Triggered when the user click on the snippet to drag&drop, remove or clone
+ * (hide the popover)
+ *
+ * @private
+ */
+ _onCoverWillChange: function () {
+ var context = this._summernote;
+ var target = context.invoke('editor.restoreTarget', target);
+ context.invoke('MediaPlugin.hidePopovers');
+ },
+ /**
+ * Triggered when a snippet is cloned in the editable area
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {Object} ev.data.$target
+ */
+ _onSnippetCloned: function (ev) {
+ this._summernote.invoke('UnbreakablePlugin.secureArea', ev.data.$target[0]);
+ },
+ /**
+ * Triggered when a snippet is dropped in the editable area
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {Object} ev.data.$target
+ */
+ _onSnippetDropped: function (ev) {
+ this._summernote.invoke('UnbreakablePlugin.secureArea', ev.data.$target[0]);
+ },
+ /**
+ * Triggered when the user focuses on a snippet: hides the popover.
+ *
+ * @private
+ */
+ _onSnippetFocused: function () {
+ var context = this._summernote;
+ var target = context.invoke('editor.restoreTarget', target);
+ context.invoke('MediaPlugin.hidePopovers');
+ context.invoke('MediaPlugin.update', target);
+ },
+});
+
+//--------------------------------------------------------------------------
+// jQuery extensions
+//--------------------------------------------------------------------------
+
+$.fn.extend({
+ /**
+ * Set the range and focus at the start of the first found node.
+ *
+ * @returns {JQuery}
+ */
+ focusIn: function () {
+ if (this.length) {
+ Wysiwyg.setRangeFromNode(this[0], {begin: true});
+ $(this).trigger('mouseup');
+ }
+ return this;
+ },
+ /**
+ * Set the range and focus at the end of the first found node.
+ *
+ * @returns {JQuery}
+ */
+ focusInEnd: function () {
+ if (this.length) {
+ Wysiwyg.setRangeFromNode(this[0], {end: true});
+ $(this).trigger('mouseup');
+ }
+ return this;
+ },
+ /**
+ * Set the range and focus on a selection, accounting for zero-width spaces.
+ *
+ * @returns {JQuery}
+ */
+ selectContent: function () {
+ if (this.length) {
+ Wysiwyg.setRangeFromNode(this[0]);
+ var range = $.summernote.range.create();
+ if (!range.sc.tagName && range.so === 0 && range.sc.textContent[range.so] === '\u200B') {
+ range.so += 1;
+ }
+ if (!range.ec.tagName && range.eo === range.ec.textContent.length && range.ec.textContent[range.eo - 1] === '\u200B') {
+ range.eo -= 1;
+ }
+ range.select();
+ $(this).trigger('mouseup');
+ }
+ return this;
+ },
+});
+
+});
diff --git a/addons/web_editor/static/src/scss/web_editor.backend.scss b/addons/web_editor/static/src/scss/web_editor.backend.scss
index 7ddbaeaa7ce..83062313b20 100644
--- a/addons/web_editor/static/src/scss/web_editor.backend.scss
+++ b/addons/web_editor/static/src/scss/web_editor.backend.scss
@@ -1,23 +1,24 @@
-.oe_form_field_html_text {
+.oe_form_field_html {
+ position: relative;
+
.note-editable {
min-height: 180px;
- font: initial !important;
+ font: inherit !important;
font-family: inherit !important;
- line-height: initial !important;
- color: initial !important;
+ line-height: inherit !important;
+ color: inherit !important;
+ overflow: visible;
+
p, div {
font-family: 'Lucida Grande', Helvetica, Verdana, Arial, sans-serif;
font-size: 13px;
}
- a, a:hover {
- color: initial;
- }
- ul > li > p {
- margin: 0px;
- }
}
- .o_readonly {
- min-height: 1em;
+ ul > li > p, p {
+ margin: 0px;
+ }
+ > iframe {
+ display: block;
width: 100%;
margin: 0;
padding: 0;
@@ -26,66 +27,15 @@
ul > li > p {
margin: 0px;
}
+ min-height: 300px;
+ min-height: -webkit-calc(100vh - 170px);
+ min-height: calc(100vh - 170px);
}
-}
-.oe_form_field_html iframe {
- display: block;
- width: 100%;
- border: 0;
- padding: 0;
- margin: 0;
- min-height: 300px;
- min-height: -webkit-calc(100vh - 170px);
- min-height: calc(100vh - 170px);
-}
-.o_field_widgetTextHtml_fullscreen {
- .oe_form_field_html.o_form_fullscreen_ancestor iframe {
- position: absolute !important;
- left: 0 !important;
- right: 0 !important;
- top: 0 !important;
- bottom: 0 !important;
- width: 100% !important;
- min-height: 100% !important;
- z-index: 1001 !important;
- border: 0;
- }
- * {
- display: none;
- }
- .o_form_fullscreen_ancestor {
- display: block !important;
- position: static !important;
- top: 0 !important;
- left: 0 !important;
- width: auto !important;
- overflow: hidden !important;
- transform: none !important;
- }
-}
-
-.openerp .oe_form .oe_form_field_html_translate textarea {
- min-width: 60px;
- width: 100%;
- color: #4c4c4c;
- font: normal 13px "Lucida Grande", Helvetica, Verdana, Arial, sans-serif;
-}
-.openerp .oe_form_readonly .oe_form .oe_form_field_html_translate textarea {
- border: 0;
- background: #fff;
-}
-.openerp .oe_form_editable .oe_form .oe_form_field_html_translate {
- > button {
- float: right;
- margin: -6px -3px 0 0;
- }
- > .note-editable {
- border: 1px solid #ccc;
- min-height: 22px;
- border-radius: 3px;
+ .rounded {
+ border-radius: .25rem !important;
}
- .note-editable [contentEditable='true']:hover {
- box-shadow: #DDCC33 0 0 5px 2px inset;
+ table.table.table-bordered {
+ table-layout: fixed;
}
}
diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss
index c082e823cc0..6f83dc28524 100644
--- a/addons/web_editor/static/src/scss/web_editor.common.scss
+++ b/addons/web_editor/static/src/scss/web_editor.common.scss
@@ -39,6 +39,46 @@ html, body {
}
}
+// List
+ul.o_checklist {
+ list-style: none;
+
+ >li {
+ position: relative;
+ margin-left: $o-checklist-margin-left;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: - $o-checklist-margin-left;
+ display: block;
+ height: $o-checklist-before-size;
+ width: $o-checklist-before-size;
+ margin-top: 4px;
+ border: 1px solid;
+ text-align: center;
+ cursor: pointer;
+ }
+ &.o_checked {
+ text-decoration: line-through;
+ &::after {
+ content: "✓";
+ position: absolute;
+ left: - ($o-checklist-margin-left - $o-checklist-checkmark-width);
+ top: +1px;
+ }
+ }
+ }
+}
+ul > li.o_indent {
+ margin-left: 0;
+ list-style: none;
+ &::before {
+ content: none;
+ }
+}
+
+
// Medias
img.shadow {
box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
@@ -55,6 +95,9 @@ img.padding-large, .img.padding-large, span.fa.padding-large, iframe.padding-lar
img.padding-xl, .img.padding-xl, span.fa.padding-xl, iframe.padding-xl {
padding: 32px;
}
+img.ml-auto, img.mx-auto {
+ display: block;
+}
.fa-6x {
font-size: 6em;
@@ -80,6 +123,7 @@ div.media_iframe_video {
text-align: center;
position: relative;
overflow: hidden;
+ min-width: 100px;
iframe {
width: 100%;
@@ -109,7 +153,7 @@ div.media_iframe_video {
}
.css_editable_mode_display {
- @include o-position-absolute;
+ @include o-position-absolute(0,0,0,0);
width: 100%;
height: 100%;
display: none;
diff --git a/addons/web_editor/static/src/scss/web_editor.ui.scss b/addons/web_editor/static/src/scss/web_editor.ui.scss
deleted file mode 100644
index 06e56ee657f..00000000000
--- a/addons/web_editor/static/src/scss/web_editor.ui.scss
+++ /dev/null
@@ -1,1616 +0,0 @@
-// Mixins for buttons with alpha bg-color
-@mixin o-alpha-button-preview {
- position: relative;
- background-image: url('/web_editor/static/src/img/transparent.png');
- background-size: 10px auto;
- z-index: 1;
-
- // Place an overlay that inherit the button's bg-color.
- &::before {
- content: "";
- @include o-position-absolute(0, 0, 0, 0);
- z-index: -1;
- background-color: inherit;
- }
-}
-
-// TOP BAR (EDIT)
-#web_editor-top-edit {
- @include o-position-absolute(0, 0, auto, 0);
- position: fixed;
- height: $o-navbar-height;
- z-index: $zindex-modal - 9;
-
- background-color: rgba(0,0,0,0);
- transition: background-color 400ms $o-we-md-ease 0s;
- font-family: $o-we-font-family;
-
- form.navbar-form {
- height: 100%;
- z-index: 1060;
- margin: 0;
- padding: 0;
- @include o-position-absolute($right: -$o-we-sidebar-width);
- transition: right 0.4s $o-we-md-ease 0s;
- border-left: 1px solid $o-we-color-divider;
- background-color: inherit;
-
- .btn-group {
- height: 100%;
- }
-
- .btn {
- height: 100%;
- margin: 0;
- padding: 10px;
- line-height: 1.2;
- font-size: 13px;
- font-family: $o-we-font-family;
-
- transition: all 0.3s ease 0s;
- border: none;
- border-radius: 0;
-
- .fa {
- margin-right: $grid-gutter-width/4;
- &.fa-times {
- color: $o-we-color-danger;
- }
- }
-
- &.btn-primary {
- @include button-variant($o-brand-odoo, $o-brand-odoo);
- }
- &.btn-secondary {
- @include button-variant($o-we-color-dark, $o-we-color-dark);
- }
-
- &:focus, &:active, &:focus:active {
- outline: none;
- }
- }
- .dropdown-menu {
- left: auto;
- right: 0;
- }
- }
-}
-
-// Translations
-.oe_translate_examples li {
- margin: 10px;
- padding: 4px;
-}
-html[lang] > body.editor_enable [data-oe-translation-state] {
- background: rgba($o-we-content-to-translate-color, 0.5)!important;
-
- &[data-oe-translation-state="translated"] {
- background: rgba($o-we-translated-content-color, 0.5)!important;
- }
- &.o_dirty {
- background: rgba($o-we-translated-content-color, 0.25)!important;
- }
-}
-
-// SNIPPET PANEL
-#oe_snippets {
- @include o-w-preserve-btn;
-
- display: flex;
- flex-flow: column nowrap;
- @include o-position-absolute(0, auto, 0, -$o-we-sidebar-width);
- position: fixed;
- width: $o-we-sidebar-width;
- z-index: $zindex-modal - 9;
-
- font-family: Roboto, $font-family-sans-serif;
- border-right: 1px solid $o-we-color-divider;
- transition: left 400ms $o-we-md-ease 0s;
- background-image: linear-gradient(45deg, $o-we-color-normal, darken($o-we-color-normal, 10%));
- box-shadow: 0px 10px 10px -10px black inset;
-
- #snippets_menu {
- flex: 0 0 auto;
- height: $o-navbar-height;
- line-height: $o-navbar-height;
- margin: 0;
- padding: 0;
- background: $o-we-color-dark;
- text-align: center;
- font-weight: normal;
- font-size: 17px;
- color: white;
- font-family: $o-we-font-family;
- }
-
- #o_scroll {
- height: 100%;
- overflow: auto;
-
- .o_panel_header {
- color: #999999;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 3%;
- color: white;
- margin-bottom: 3%;
-
- i {
- margin-right: 5px;
- }
- }
- .o_panel_body {
- &:after {
- content: " ";
- display: table;
- clear: both;
- }
-
- .oe_snippet {
- float: left;
- width: 45.5%;
- margin: 0 0 6% 3%;
- box-shadow: none;
- background-color: transparent;
- user-select: none;
-
- cursor: pointer;
- cursor: copy;
- cursor: grab;
-
- .oe_snippet_thumbnail {
- .oe_snippet_thumbnail_img {
- border: none;
- }
- .oe_snippet_thumbnail_title {
- border: none;
- padding: 5px 0;
- }
-
- &:hover .oe_snippet_thumbnail_img {
- transition: transform 200ms ease 0s;
- transform: scale(1.05);
- }
- }
-
- &.o_disabled {
- .oe_snippet_thumbnail {
- background-color: rgba(255, 0, 0, 0.75);
- }
- .oe_snippet_thumbnail_img {
- opacity: 0.8;
- }
- }
-
- &.o_snippet_install {
- .oe_snippet_thumbnail_img {
- opacity: 0.5;
- }
- .btn.o_install_btn {
- display: none;
- @include o-position-absolute(16px, 4px, auto, 4px);
- }
- &:hover .btn.o_install_btn {
- display: block;
- }
- }
-
- &:nth-child(2n+1) {
- clear: left;
- }
- }
- }
- }
-}
-.oe_snippet { // No root because can be drag and drop (and the helper is in the body)
- position: relative;
- width: 100px;
- background-color: $o-we-color-normal;
-
- > :not(.oe_snippet_thumbnail) {
- display: none!important;
- }
-
- .oe_snippet_thumbnail {
- width: 100%;
-
- .oe_snippet_thumbnail_img {
- width: 100%;
- height: 0;
- border: 1px solid $o-we-color-normal;
- padding-bottom: 75%;
- background-size: cover;
- background-position: center center;
- text-align: center;
- overflow: hidden;
- }
- img.oe_snippet_thumbnail_img {
- height: auto;
- padding-bottom: 0;
- }
- .oe_snippet_thumbnail_title {
- display: block;
- border: 1px solid $o-we-color-dark;
- padding: 5px;
- font-size: 12px;
- font-weight: 300;
- text-shadow: none;
- color: $o-we-color-text-light;
- }
- &:hover .oe_snippet_thumbnail_title {
- color: white;
- }
- }
-}
-
-// DROPZONES
-@keyframes dropZoneInsert {
- to {
- background-color: rgba($o-brand-odoo, 0.2);
- }
-}
-#wrapwrap .oe_drop_zone {
- background-color: rgba($o-brand-odoo, 0.05);
- animation: dropZoneInsert 1s linear 0s infinite alternate;
-
- &.oe_insert {
- z-index: 999;
-
- border: $o-we-dropzone-border;
- border-top: none;
- border-bottom: none;
-
- position: relative;
- width: 100%;
- height: $o-we-dropzone-size;
- margin: (-$o-we-dropzone-size/2) 0;
-
- &:after {
- content: "";
- display: block;
- width: 100%;
- height: 50%;
- border-bottom: $o-we-dropzone-border;
- box-sizing: content-box;
- }
-
- &.oe_vertical {
- border: 2px dashed $o-brand-odoo;
- border-left: none;
- border-right: none;
-
- width: $o-we-dropzone-size;
- float: left;
- margin: 0 (-$o-we-dropzone-size/2);
-
- &:after {
- width: 50%;
- height: 100%;
- border-bottom: none;
- border-right: $o-we-dropzone-border;
- }
- }
- }
-}
-
-// MANIPULATORS
-#oe_manipulators {
- position: relative;
-
- // SNIPPET MANIPULATORS
- .oe_overlay {
- @include o-position-absolute;
- z-index: 1002;
- display: none;
- height: 0;
- background: transparent;
- text-align: center;
- transition: opacity 400ms linear 0s;
- &.o_keypress {
- opacity: 0;
- }
- &.oe_active {
- display: block;
- }
-
- // OVERLAY OPTIONS
- > .oe_overlay_options {
- font-family: $o-we-font-family;
- @include o-position-absolute($bottom: 0, $left: 0);
- width: 250px; // the parent oe_overlay has a width equal to the snippet width, the 250px is there to force the options to stay on one line without overriding bootstrap .btn-group
- text-align: left;
-
- > .btn-group {
- white-space: nowrap;
-
- .btn {
- display: inline-block;
- width: $o-we-overlay-option-size;
- height: $o-we-overlay-option-size;
- padding: 0;
- border: 1px solid $o-we-color-dark;
- line-height: $o-we-overlay-option-size;
- font-size: 11px;
- border-radius: 0;
- font-weight: normal;
-
- transition: all 400ms ease 0s;
- color: $o-we-color-text-light;
- background-color: $o-we-color-light;
- &:hover {
- color: white;
- background-color: lighten($o-we-color-dark, 10%);
-
- > .fa {
- color: white;
- }
- }
-
- &.oe_snippet_remove {
- color: white;
- background-color: $o-we-color-danger;
- border-color: darken($o-we-color-danger, 20%);
-
- > .fa {
- color: white;
- }
-
- &:hover {
- background-color: darken($o-we-color-danger, 20%);
- }
- }
- &.oe_snippet_parent > i {
- transform: scaleX(-1);
- font-size: 12px;
- }
-
- > .fa {
- color: $o-we-color-text-light;
- }
- }
-
- // CUSTOMIZE MENU BUTTON
- > .oe_options {
- .btn {
- width: auto;
- padding: 0 25px 0 5px;
- background-color: $o-we-color-dark;
- text-transform: uppercase;
- &:hover, &:active, &:focus {
- background-color: lighten($o-we-color-dark, 5%);
- border-color: lighten($o-we-color-dark, 10%);
- color: white;
- }
-
- &:before, &:after {
- content: "";
- width: 7px;
- height: 2px;
- background-color: $o-we-color-text-light;
- @include o-position-absolute(46%);
- transition: all 0.3s ease 0s;
- }
- &:before {
- right: 9px;
- transform: rotate(45deg);
- }
- &:after {
- right: 5px;
- transform: rotate(-45deg);
- }
- }
- // Open menu
- &.show .btn {
- // background-color: $o-we-color-light;
- &:active, &:focus, &:active:focus {
- box-shadow: none;
- }
- &:before, &:after {
- width: 9px;
- }
- &:before {
- right: 10px;
- transform: translateX(5px) rotate(-45deg);
- }
- &:after {
- transform: rotate(45deg);
- }
- }
-
- // CUSTOMIZE MENU
- .dropdown-menu {
- margin: -1px 0 0 0;
- padding: 0;
- border: 1px solid $o-we-color-dark;
- border-radius: 0;
- background-color: $o-we-color-light;
-
- .dropdown-item {
- position: relative;
- color: $o-we-color-text-light;
- font-weight: normal;
- font-size: 12px;
- padding: 5px 40px 5px 10px;
-
- &:hover {
- background-color: fade-out($o-we-color-dark, 0.5);
- color: white;
- background: transparent;
- cursor: pointer;
- }
- &.active {
- color: white;
- background-color: fade-out($o-we-color-dark, 0.5);
- &:before {
- @include o-position-absolute($top: 5px, $right: 5px);
- content: "\f00c";
- font-family: "FontAwesome";
- color: $o-brand-primary;
- }
- }
- > i {
- width: 15px;
- margin-right: 10px;
- text-align: center;
- }
- }
-
- .dropdown-submenu {
- position: relative;
- &::before {
- @include o-position-absolute($top: 12px, $right: 10px);
- @include o-caret-right(4px);
- border-left-color: $o-we-color-text-light;
- }
- &:hover > .dropdown-menu {
- display: block;
- left: 100%;
- top: 0;
- &.o_open_to_left {
- left: auto;
- right: 100%;
- }
- }
- }
-
- .dropdown-divider {
- display: block;
- height: 1px;
- border-top: 1px solid #666;
- margin: 0;
- }
-
- .dropdown-header {
- color: wheat;
- font-size: 11px;
- margin-top: 2px;
- text-transform: uppercase;
- &:hover {
- background-color: initial;
- }
-
- &.o_main_header {
- padding-left: 10px;
- }
- &.o_parent_editor_header {
- position: relative;
- color: wheat;
- font-weight: 400;
- font-size: 10px;
- margin: 10px 0 0 10px;
- text-transform: uppercase;
- padding-bottom: .25em;
- &::before {
- content: "\f148";
- @include o-position-absolute($top: 5px, $left: 4px);
- font-family: FontAwesome;
- transform: scaleX(-1);
- }
- ~ .dropdown-submenu .dropdown-item,
- ~ .dropdown-item {
- font-size: 11px;
- opacity: 0.7;
- &:hover {
- opacity: 1;
- }
- }
- ~ .dropdown-submenu::before {
- @include o-position-absolute($top: 10px, $right: 10px);
- }
- }
- }
- }
- }
- }
- }
-
- &.o_top_cover > .oe_overlay_options {
- bottom: auto;
- top: $o-we-handle-border-width;
- right: $o-we-handle-border-width;
- }
-
- // HANDLES
- > .o_handles {
- @include o-position-absolute(-$o-we-handles-offset-to-hide, 0, auto, 0);
-
- &:hover > .o_handle {
- background-color: rgba($o-we-handles-accent-color, 0.05);
- }
-
- > .o_handle {
-
- position: relative;
- border: 0 solid $o-we-handles-accent-color;
- transition: background 300ms ease 0s;
-
- &:hover, &.o_active {
- background-color: rgba($o-we-handles-accent-color, 0.2);
-
- &:before {
- content: '';
- @include o-position-absolute(0, $left:50%);
- width: 1px;
- height: 100%;
- margin-left: -1px;
- background-color: rgba($o-we-handles-accent-color, 0.5);
- }
-
- &.w:before {
- @include o-position-absolute(50%, $left: 0);
- width: 100%;
- height: 1px;
- }
-
- &:after {
- border-color: darken($o-we-handles-accent-color, 10%);
- background-color: darken($o-we-handles-accent-color, 20%);
- }
- }
-
- &:after {
- display: block;
- width: $o-we-handles-btn-size;
- height: $o-we-handles-btn-size;
- border: solid 1px darken($o-we-handles-accent-color, 10%);
- line-height: $o-we-handles-btn-size - 2;
- font-size: 14px;
- font-family: FontAwesome;
- background-color: darken($o-we-handles-accent-color, 20%);
- color: white;
- }
- &.w:after, &.e:after {
- content: "\f07e";
- }
- &.s:after, &.n:after {
- content: "\f07d";
- }
-
- &.o_handle_start {
- &.w:after, &.e:after {
- content: '\f061';
- }
- &.n:after, &.s:after {
- content: '\f063';
- }
- }
- &.o_handle_end {
- &.w:after, &.e:after {
- content: '\f060';
- }
- &.n:after, &.s:after {
- content: '\f062';
- }
- }
-
- &.w {
- @include o-position-absolute($o-we-handles-offset-to-hide, auto, -$o-we-handles-offset-to-hide, 0);
- width: $o-we-handle-edge-size;
- border-width: $o-we-handle-border-width;
- border-right-width: 0;
- cursor: e-resize;
-
- &:after {
- @include o-position-absolute($top: 50%, $left: 40%);
- margin-top: -$o-we-handles-btn-size/2;
- }
- }
- &.e {
- @include o-position-absolute($o-we-handles-offset-to-hide, 0, -$o-we-handles-offset-to-hide, auto);
- width: $o-we-handle-edge-size;
- border-right-width: $o-we-handle-border-width;
- cursor: w-resize;
-
- &:after {
- @include o-position-absolute($top: 50%, $right: 40%);
- margin-top: -$o-we-handles-btn-size/2;
- }
- }
- &.n {
- @include o-position-absolute($o-we-handles-offset-to-hide, 0, auto, 0);
- border-top-width: $o-we-handle-border-width;
- cursor: ns-resize;
-
- &:after {
- @include o-position-absolute($left: 50%, $top: 40%);
- margin-left: -$o-we-handles-btn-size/2;
- }
- }
- &.s {
- @include o-position-absolute(auto, 0, -$o-we-handles-offset-to-hide, 0);
- border-bottom-width: $o-we-handle-border-width;
- cursor: ns-resize;
-
- &:after {
- @include o-position-absolute($left: 50%, $bottom: 40%);
- margin-left: -$o-we-handles-btn-size/2;
- }
- }
-
- &.readonly {
- cursor: auto!important;
-
- &:after {
- display: none!important;
- }
- &:hover {
- opacity: 0.5;
- }
- }
- }
- }
- }
-}
-
-.s-resize-important * {
- cursor: s-resize !important;
-}
-.n-resize-important * {
- cursor: n-resize !important;
-}
-.e-resize-important * {
- cursor: e-resize !important;
-}
-.w-resize-important * {
- cursor: w-resize !important;
-}
-.move-important * {
- cursor: move !important;
-}
-
-// NOTE EDITOR
-.note-popover .popover {
- height: $o-navbar-height;
- top: 0 !important;
- left: 0 !important;
- padding: 0;
- margin: 0 0 0 $o-we-sidebar-width;
- background-color: $o-we-color-dark;
- border-radius: 0;
- border-width: 0 1px;
- width: auto;
- text-align: center;
- box-shadow: none;
-
- .popover-body {
- height: $o-navbar-height;
- white-space: nowrap;
- font-family: $o-we-font-family;
-
- h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
- font-family: $o-we-font-family;
- }
-
- .btn {
- height: $o-navbar-height;
- border: none;
- border-radius: 0;
- padding: $btn-padding-y $btn-padding-y; // force this because of themes
- background-color: $o-we-color-dark;
- color: $o-we-color-text-normal;
- border-top: 2px solid transparent;
- transition: all 0.3s ease 0s;
-
- > .fa {
- color: $o-we-color-text-normal;
- }
-
- &:hover {
- border-top: 2px solid $o-we-color-text-normal;
- color: $o-we-color-text-light;
-
- > .fa {
- color: $o-we-color-text-light;
- }
- }
-
- .o_image_alt {
- @include o-text-overflow(inline-block);
- max-width: 100px;
- }
-
- &.active {
- border-top: 2px solid white;
- background-color: $o-we-color-dark;
- color: $o-we-color-text-light;
- box-shadow: inset 0 3px 10px rgba(0, 0, 0, 0.5);
-
- > .fa {
- color: $o-we-color-text-light;
- }
- }
- }
- > .btn-group {
- margin-top: -2px;
-
- &.show, .btn-group.show {
- .dropdown-toggle {
- border-top: 2px solid white;
- background-color: $o-we-color-darker;
- color: white;
- box-shadow: none;
-
- > .fa, > span {
- color: white;
- }
- }
- }
- }
- .dropdown-menu {
- margin: 0;
- border-radius: 0;
- background-color: $o-we-color-darker;
- color: $o-we-color-text-normal;
- white-space: normal;
-
- .dropdown-item {
- width: 100%;
- color: $o-we-color-text-light;
-
- > .fa {
- color: $o-we-color-text-light;
- }
-
- &:hover {
- background-color: $o-we-tab-active-bg;
- color: white;
-
- > .fa {
- color: white;
- }
- }
- }
- }
- .note-para .dropdown-menu {
- min-width: 228px;
- }
-
- .note-color-palette .note-color-row, .note-custom-color-palette .note-color-row {
- height: auto!important;
-
- &::after {
- content: "";
- display: table;
- clear: both;
- }
-
- .note-color-btn {
- float: left;
- height: 20px;
- width: 20px;
- margin: 1px 1px 0 0;
- border: 1px solid black;
- @include o-alpha-button-preview;
-
- &.o_small {
- width: 16px;
- height: 16px;
- margin: 3px 3px 2px 2px;
- border-radius: 50%;
- }
-
- &.o_clear {
- clear: both;
- }
-
- &.o_btn_transparent {
- display: none;
- }
- }
- }
- }
-}
-
-.note-popover .popover, .note-editor {
- .dropdown-menu {
- .dropdown-item {
- > i {
- visibility: hidden;
- }
- &.checked > i {
- visibility: visible;
- }
- }
- }
-
- .note-color {
- .note-back-color-preview, .note-fore-color-preview {
- .dropdown-toggle {
- padding: 0px 7px;
- height: 35px;
- width: auto;
- border-top: none !important;
- border-bottom: 3px solid;
- &:after {
- display: none;
- }
- }
- }
- .o_foreground_toggle > button:before {
- font-family: FontAwesome;
- content: '\f1fc';
- }
- .o_background_toggle > button:before {
- font-family: FontAwesome;
- content: '\f0c3';
- }
- .dropdown-menu {
- min-width: 180px!important;
- .note-custom-color {
- cursor: pointer;
- border-radius: 5px;
- &:hover {
- background-color: gray('400');
- color: black
- }
- }
- }
- }
-}
-
-table.colorpicker {
- width: 100%;
- td {
- padding: 0 2px 2px 0;
- > button {
- width: 100%;
- border: 1px solid black;
- padding: 0;
- &::after {
- content: "A";
- font-weight: bold;
- vertical-align: sub;
- }
- }
- }
-}
-.colorpicker {
- background-color: $o-we-color-dark!important;
-
- .o_colorpicker_sections {
- border: 1px solid darken($o-we-color-dark, 12%);
- font-size: 14px;
-
- .o_colorpicker_section_tabs {
- width: 225px;
- height: 100%;
- }
- }
- .o_colorpicker_section {
- &:after {
- content: "";
- display: table;
- clear: both;
- }
-
- button {
- float: left;
- height: 25px;
- width: 25px;
- margin: 1px 1px 0 2px;
- border: 1px solid black;
- @include o-alpha-button-preview;
-
- &.o_small {
- width: 23px;
- height: 23px;
- margin: 2px;
- border-radius: 50%;
-
- + :not(.o_small) {
- clear: both;
- }
- }
-
- &:hover, &.selected {
- box-shadow: 0px 0px 2px 2px $o-we-color-light;
- }
-
- &.selected:before {
- content: "\f00c";
- font-family: "FontAwesome";
- padding-top: 1px;
- color: theme-color('success');
- }
-
- &[data-event="foreColor"] {
- background-color: $o-we-color-normal;
- &:before {
- background-color: rgba(white, 0.3);
- }
- }
-
- &.o_btn_transparent::before {
- background-color: transparent;
- }
- }
- }
- .note-palette-title {
- padding: 10px 0px 10px 10px;
- color: $o-we-color-text-light;
- font-weight: bold;
- }
- .palette-reset {
- @include o-position-absolute(0, 0);
- margin: 0;
- padding: 5px 10px 0;
- .note-color-reset {
- font-size: 20px!important;
- color: desaturate(rgba($o-we-color-danger, 0.6), 40%);
- margin: 1px 0 0;
- padding: 0;
- cursor: pointer;
- &:hover {
- background: transparent!important;
- color: $o-we-color-danger;
- }
- }
- }
-}
-
-// ENTER IN EDIT MODE
-body.editor_enable {
- padding-top: $o-navbar-height!important;
-
- #web_editor-top-edit {
- background-color: $o-we-color-dark;
- form.navbar-form {
- right: 0;
- }
- }
-
- &.editor_has_snippets {
- transition: padding-left 400ms $o-we-md-ease 0s;
- padding-left: $o-we-sidebar-width!important;
-
- #oe_snippets {
- left: 0;
- }
- }
- .btn {
- -webkit-user-select: none;
- }
-}
-
-#web_editor_inside_iframe {
- background-color: $o-brand-secondary;
-
- #wrapwrap {
- position: relative;
- display: table;
- table-layout: fixed;
- width: 100%;
- height: 100%;
-
- > * {
- display: table-row;
- }
- > main {
- height: 100%;
-
- #editable_area {
- position: relative;
- width: 100%;
- height: 100%;
- background-color: white;
- }
- }
- }
-}
-
-// Animations
-@keyframes fadeInDownSmall {
- 0% {
- opacity: 0;
- transform: translate(0, -5px);
- }
- 100% {
- opacity: 1;
- transform: translate(0, 0);
- }
-}
-@keyframes fadeInOut {
- 0% {
- opacity: 0;
- }
- 25% {
- opacity: 1;
- }
- 75% {
- opacity: 1;
- }
- 100% {
- opacity: 0;
- }
-}
-@keyframes inputHighlighter {
- from { background: $o-brand-primary; }
- to { width: 0; background: transparent; }
-}
-
-// INPUTS
-.o_switch {
- display: flex;
- align-items: center;
- font-weight: normal;
- cursor: pointer;
-
- > input {
- display: none;
-
- + span {
- background-color: $o-we-switch-inactive-color;
- box-shadow: inset 0 0 0px 1px darken($o-we-switch-inactive-color,10%);
- border-radius: 100rem;
- height: $o-we-switch-size;
- width: $o-we-switch-size * 1.8;
- margin-right: 0.5em;
- display: inline-block;
- transition: all 0.3s $o-we-md-ease;
-
- &:after {
- content: "";
- background: $o-we-color-paper;
- display: block;
- width: $o-we-switch-size - 0.2;
- height: $o-we-switch-size - 0.2;
- margin-top: 0.1ex;
- margin-left: 0.1ex;
- border-radius: 100rem;
- transition: all 0.3s $o-we-md-ease;
- box-shadow: 0 1px 1px darken($o-we-switch-inactive-color,35%), inset 0 0 0 1px lighten($o-we-switch-inactive-color,10%);
- }
- }
-
- &:checked + span {
- box-shadow: none;
- background-image: linear-gradient(0deg, $o-we-color-success, darken($o-we-color-success, 10%));
-
- &:after {
- margin-left: ($o-we-switch-size*1.8 - $o-we-switch-size) + 0.1;
- }
- }
- }
- &.o_switch_danger {
- > input {
- &:not(:checked) + span {
- box-shadow: none;
- background-image: linear-gradient(0deg, lighten(theme-color('danger'), 5%), darken(theme-color('danger'), 5%));
- }
- }
- }
-}
-
-.dropdown-menu label .o_switch {
- margin: 0;
- padding: 2px 0;
-}
-
-.text-input-group {
- position: relative;
- margin-bottom: 45px;
-
- input {
- font-size: 18px;
- padding: 10px 10px 10px 5px;
- display: block;
- width: 300px;
- border: none;
- border-bottom: 1px solid #757575;
- }
- input:focus { outline: none; }
-
- /* LABEL ======================================= */
- label {
- color: #999;
- font-size: 18px;
- font-weight: normal;
- @include o-position-absolute($top: 10px, $left: 5px);
- pointer-events: none;
- transition: 0.2s ease all;
- }
-
- /* active state */
- input:focus ~ label, input:valid ~ label {
- top: -20px;
- font-size: 14px;
- color: #5264AE;
- }
-
- /* BOTTOM BARS ================================= */
- .bar { position: relative; display: block; width: 300px; }
- .bar:before, .bar:after {
- content: '';
- height: 2px;
- width: 0;
- bottom: 1px;
- @include o-position-absolute;
- background: #5264AE;
- transition: 0.2s ease all;
- }
- .bar:before {
- left: 50%;
- }
- .bar:after {
- right: 50%;
- }
-
- /* active state */
- input:focus ~ .bar:before, input:focus ~ .bar:after {
- width: 50%;
- }
-
- /* HIGHLIGHTER ================================== */
- .highlight {
- @include o-position-absolute($top: 25%, $left: 0);
- height: 60%;
- width: 100px;
- pointer-events: none;
- opacity: 0.5;
- }
-
- /* active state */
- input:focus ~ .highlight {
- animation: inputHighlighter 0.3s ease;
- }
-}
-
-// DRAG&DROP ANIMATIONS
-.oe_snippet_body {
- opacity: 0;
- animation: fadeInDownSmall 700ms forwards;
-}
-
-// ACE EDITOR
-.o_ace_view_editor {
- background: $o-we-ace-color;
- color: white;
- display: flex;
- flex-flow: column nowrap;
- opacity: 0.97;
-
- .o_ace_view_editor_title {
- flex: 0 0 auto;
- display: flex;
- align-items: center;
- padding: $grid-gutter-width/4;
-
- > .o_ace_type_switcher > button::after {
- @include o-caret-down;
- margin-left: 4px;
- }
-
- > * {
- flex: 0 0 auto;
- margin: 0 $grid-gutter-width/4;
-
- &.o_include_option {
- display: flex;
- align-items: center;
- font-size: 11px;
-
- > .custom-control {
- margin-right: $grid-gutter-width/4;
- }
- }
-
- &.o_res_list {
- flex: 1 1 auto;
- min-width: 60px;
- }
- }
- }
- #ace-view-id {
- flex: 0 0 auto;
- padding: $grid-gutter-width/4 $grid-gutter-width/2;
- background-color: lighten($o-we-ace-color, 10%);
- }
- #ace-view-editor {
- @mixin ace-line-error-mixin {
- content: "";
- z-index: 1000;
- display: block;
- background-color: theme-color('danger');
- opacity: 0.5;
- pointer-events: none;
- }
-
- height: 70%; // in case flex is not supported
- flex: 1 1 auto;
- .ace_gutter {
- display: block!important; // display even with aria-hidden
- cursor: ew-resize;
-
- .ace_gutter-cell.o_error {
- position: relative;
- &::after {
- @include o-position-absolute(-100%, 0, -100%, 0);
- @include ace-line-error-mixin;
- }
- }
- }
- .ace_resize_bar {
- @include o-position-absolute($right: 0);
- width: 25px;
- height: 100%;
- cursor: ew-resize;
- }
- .ace_scroller.o_error::after {
- @include o-position-absolute(0, auto, 0, 0);
- width: 3px;
- @include ace-line-error-mixin;
- }
- }
-}
-.o_ace_select2_dropdown {
- width: auto!important;
- padding-top: 4px;
- font-family: monospace!important;
-
- > .select2-results {
- max-height: none;
- max-height: 70vh;
-
- .select2-result-label {
- padding-top: 1px;
- padding-bottom: 2px;
-
- >.o_ace_select2_result {
- padding: 0;
- font-size: 12px;
- white-space: nowrap;
- }
- }
- }
-}
-
-// MODALS
-body .modal {
- // SELECT MEDIA
- .o_select_media_dialog {
- max-width: 80%;
-
- .modal-body {
- .tab-pane {
- min-height: 300px;
- }
-
- .o_existing_attachment_cell {
- $o-border-image: 2px;
-
- .o_existing_attachment_remove {
- @include o-position-absolute($o-border-image, 15px + $o-border-image);
- padding: 3px 5px 5px 5px;
- background-color: rgba(white, 0.8);
- text-shadow: 0 0 1px white;
- border-radius: 0 0 0 2px;
- opacity: 0;
- cursor: pointer;
-
- &:hover {
- background-color: white;
- color: $o-we-color-danger;
- }
- }
- .o_image {
- border: $o-border-image solid transparent;
- cursor: pointer;
- transition: opacity 400ms ease 0s;
-
- &.o_webimage {
- width: 100%;
- height: auto;
- background-image: none!important;
- }
-
- &.o_image_loading {
- opacity: 0;
- }
- }
- &.o_selected .o_image {
- border-color: $o-brand-odoo;
- box-shadow: 0px 0px 2px 2px $o-brand-secondary;
- }
- &:hover .o_existing_attachment_remove {
- opacity: 1;
- }
- }
- .font-icons-icons {
- > span {
- text-align: center;
- font-size: 22px;
- margin: 5px;
- width: 50px;
- height: 50px;
- padding: 15px;
- cursor: pointer;
-
- &.o_selected {
- cursor: auto;
- background-color: $o-we-color-paper;
- box-shadow: 0px 0px 0px 1px $o-we-color-success;
- }
- }
- }
-
- #editor-media-image, #editor-media-document {
- .o_we_url_input {
- width: 300px;
- }
- }
-
- // VIDEO TAB
- #editor-media-video {
- .o_video_dialog_form {
- #o_video_form_group {
- position: relative;
- width: 100%;
-
- > textarea {
- width: 100%;
- min-height: 95px;
- padding-bottom: 25px;
- overflow-y: scroll;
- }
-
- .o_validate_feedback {
- @include o-position-absolute(auto, 10px, 10px);
-
- > .fa {
- display: none;
- }
- }
- &.o_has_error .o_validate_feedback > .fa-exclamation-triangle {
- display: block;
- }
- &.o_has_success .o_validate_feedback > .fa-check {
- display: block;
- }
- }
- }
-
- #video-preview {
- position: relative;
-
- @include o-we-preview-box();
- border: none;
-
- .media_iframe_video {
- width: 100%;
- }
-
- .o_video_dialog_iframe {
- @include o-we-preview-content;
- max-width: 100%;
- max-height: 100%;
-
- &.alert {
- animation: fadeInDownSmall 700ms forwards;
- margin: 0 auto;
- }
- }
- }
- }
- }
- }
-
- // BACKGROUND IMAGE OPTIONS
- .o_bg_img_opt_modal .o_bg_img_opt {
- margin: 15px 0;
-
- .help-control {
- @include o-position-absolute(15px, 15px);
- }
- .help {
- margin: 15px 0;
-
- .simulator {
- position: relative;
- float: left;
- margin-right: 15px;
- width: 120px;
- height: 100px;
- .bg {
- @include o-position-absolute(0, 0, 0, 0);
- border: 1px dotted #888787;
- background-image: url(/web/image);
- background-position: center center;
- background-size: cover;
- }
- .el {
- @include o-position-absolute(10px, 0, 10px, 0);
- border: 1px solid #222;
- }
- &.contain_bg {
- .bg {
- @include o-position-absolute(0, 12px, 20px, 12px);
- }
- .el {
- @include o-position-absolute(0, 0, 20px, 0);
- }
- }
- }
- + * {
- clear: left;
- }
- }
-
- .o_bg_img_opt_cover_edition {
- @include o-we-preview-box;
- padding: 0 25px 25px 25px;
-
- h6 {
- color: white;
- font-weight: bold;
- }
- .o_bg_img_opt_object {
- position: relative;
- background: white;
- min-height: 10px;
- @include o-we-preview-content;
-
- > img {
- cursor: crosshair;
- border-top: 1px solid #5A5A5A;
- border-bottom: 1px solid #111;
- }
- &:hover .o_focus_point:before {
- opacity: 0.5;
- }
- }
- .o_bg_img_opt_ui_info {
- animation: fadeInOut 2s ease forwards;
- @include o-position-absolute($bottom: 1px, $right: 0);
- display: block;
- padding: 3px 13px;
- background-color: rgba(255, 255, 255, 0.8);
- color: #333;
- text-align: center;
- font-weight: bold;
- pointer-events: none;
- span {
- font-weight: normal;
- }
- .o_x {
- margin-right: 10px
- }
- }
- .grid {
- @include o-position-absolute($top: 33.33%, $left: 0);
- display: block;
- width: 100%;
- height: 1px;
- background: fade-out(lighten($o-brand-primary, 30%), 0.5);
- pointer-events: none;
- &.grid-2 {
- top: 66.66%
- }
- &.grid-3 {
- top: 0;
- left: 33.33%;
- width: 1px;
- height: 100%
- }
- &.grid-4 {
- top: 0;
- left: 66.66%;
- width: 1px;
- height: 100%
- }
- }
- .o_focus_point {
- @include o-position-absolute($top: 0, $left: 0);
- width: 30px;
- height: 30px;
- border: 2px solid white;
- border-radius: 100%;
- margin-top: -15px;
- margin-left: -15px;
- box-shadow: 0 0 1px #333;
- pointer-events: none;
- &.o_with_transition {
- transition: all 0.2s ease 0s;
- }
- &:before {
- pointer-events: none;
- content: "";
- display: block;
- width: 100px;
- height: 100px;
- margin-top: -37px;
- margin-left: -37px;
- border: 1px solid #EAEAEA;
- background: rgba(247, 76, 76, 0);
- border-radius: 100em;
- opacity: 1;
- box-shadow: 0 0 0 100em rgba(0, 0, 0, 0.33);
- transition: opacity 0.2s ease 0s;
- }
- }
- }
- }
-
- // LINK EDITOR DIALOG COLOR SELECTOR
- .o_link_dialog {
- .o_link_dialog_color > .o_link_dialog_color_item {
- position: relative;
- width: 30px;
- height: 30px;
- padding: 0; // Important for themes
- border: 2px solid rgba(0, 0, 0, 0.25);
- border-radius: 50%;
-
- input:checked + i::before {
- content: "\f00c";
- @include o-position-absolute($top: 6px, $left: 5px);
- }
-
- &.btn-link {
- padding: 3px 9px;
-
- > span {
- text-decoration: underline;
- }
- }
- }
- .o_link_dialog_preview {
- border-left: 1px solid gray('200');
- }
- }
- // Crop Dialog
- .o_crop_image_dialog .o_crop_area {
- height: 350px;
- .cropper-point {
- background-color: white;
- transition: all 400ms $o-we-md-ease 0s;
- transform: scale(1.5, 1.5);
- border-radius: 10px;
- box-shadow: 0 0 1px 1px rgba(23, 23, 23, 0.5);
- opacity: 1;
- &:hover {
- transform: scale(2.5, 2.5);
- }
- }
- .cropper-line {
- background: black;
- }
- .cropper-view-box {
- outline: 2px solid white;
- }
- }
-}
-
-
-// Highlight selected image/icon
-%o-we-selected-image {
- $o-selected-image-color: rgba(150, 150, 220, 0.3);
- background: $o-selected-image-color;
- outline: 3px solid $o-selected-image-color;
-}
-img.o_we_selected_image {
- @extend %o-we-selected-image;
-}
-.fa.o_we_selected_image::before {
- @extend %o-we-selected-image;
-}
diff --git a/addons/web_editor/static/src/scss/web_editor.variables.scss b/addons/web_editor/static/src/scss/web_editor.variables.scss
index e556d6e5e4e..a66e3516c77 100644
--- a/addons/web_editor/static/src/scss/web_editor.variables.scss
+++ b/addons/web_editor/static/src/scss/web_editor.variables.scss
@@ -12,6 +12,7 @@ $o-we-color-normal: #50545d !default;
$o-we-color-paper: #fcfcfc !default;
$o-we-color-text-normal: #999999 !default;
$o-we-color-text-light: #d4d5d7 !default;
+$o-we-color-text-lighter: #ebecee !default;
$o-we-color-danger: #e6586c !default;
$o-we-color-warning: #f0ad4e !default;
$o-we-color-success: #40ad67 !default;
@@ -42,6 +43,11 @@ $o-we-ace-color: #2F3129 !default;
$o-we-switch-size: 2ex !default;
$o-we-switch-inactive-color: $o-we-color-paper !default;
+// Checklist
+$o-checklist-margin-left: 20px;
+$o-checklist-checkmark-width: 2px;
+$o-checklist-before-size: 13px;
+
// ============ 'Preview' component Mixins ===============
// Create a "preview" work-area to edit/customize specific elements
diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss
new file mode 100644
index 00000000000..9a61755acf8
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg.scss
@@ -0,0 +1,522 @@
+// NOTE EDITOR
+
+.note-editor.note-frame {
+ background-color: inherit;
+
+ .note-editing-area .note-editable {
+ background-color: transparent !important;
+ color: inherit;
+ padding-left: 0;
+ padding-right: 0;
+ }
+
+ &.fullscreen {
+ background-color: #fff;
+ }
+}
+
+.note-popover.popover {
+ z-index: $o-wysiwyg-zindex;
+ height: auto;
+
+ .popover-content {
+ padding-bottom: 0;
+ max-width: 334px;
+
+ >note-btn-group {
+ white-space: nowrap;
+ }
+ }
+}
+
+.note-editor .note-toolbar {
+ white-space: nowrap;
+ z-index: $o-wysiwyg-zindex + 10;
+ height: $o-wysiwyg-toolbar-height;
+}
+
+.note-popover.popover .popover-content,
+.note-editor .note-toolbar {
+ font-family: $o-wysiwyg-font-family;
+ background-color: $o-wysiwyg-bg-color;
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6,
+ .h1,
+ .h2,
+ .h3,
+ .h4,
+ .h5,
+ .h6 {
+ font-family: $o-wysiwyg-font-family;
+ }
+
+ .btn {
+ height: $o-wysiwyg-toolbar-height;
+ border: none;
+ border-radius: 0;
+ background-color: inherit;
+ color: inherit;
+ border-top: 2px solid transparent;
+ transition: all 0.3s ease 0s;
+
+ >.fa {
+ color: inherit;
+ }
+
+ &:hover {
+ border-top: 2px solid inherit;
+ color: inherit;
+
+ >.fa {
+ color: inherit;
+ }
+ }
+
+ .o_image_alt {
+ @include o-text-overflow(inline-block);
+ max-width: 100px;
+ }
+
+ &.active {
+ border-top: 2px solid white;
+ background-color: inherit;
+ color: inherit;
+ box-shadow: inset 0 3px 10px rgba(0, 0, 0, 0.5);
+
+ >.fa {
+ color: inherit;
+ }
+ }
+
+ &.o_disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+ }
+
+ .note-btn-group {
+ margin: 0;
+ }
+
+ >.btn-group {
+ margin-top: 0;
+
+ &.show,
+ .btn-group.show {
+ .dropdown-toggle {
+ border-top: 2px solid white;
+ background-color: $o-wysiwyg-bg-color-dark;
+ box-shadow: none;
+
+ >.fa,
+ >span {
+ color: inherit;
+ }
+ }
+ }
+ }
+
+ .dropdown-menu {
+ margin: 0;
+ border-radius: 0;
+ background-color: $o-wysiwyg-bg-color;
+ color: inherit;
+ white-space: normal;
+
+ .dropdown-item {
+ width: 100%;
+ color: inherit;
+
+ >.fa {
+ color: inherit;
+ }
+
+ &:hover {
+ background-color: $o-wysiwyg-bg-color-dark;
+ color: inherit;
+
+ >.fa {
+ color: inherit;
+ }
+ }
+ }
+ }
+
+ .note-para .dropdown-menu {
+ min-width: 0;
+ white-space: nowrap;
+ }
+
+ .note-color {
+
+ .note-back-color-preview,
+ .note-fore-color-preview {
+ .dropdown-toggle {
+ padding: 0px 7px;
+ height: 35px;
+ width: auto;
+ border-top: none;
+ border-bottom: 3px solid;
+
+ &:after {
+ display: none;
+ }
+ }
+ }
+
+ .dropdown-menu {
+ min-width: 180px;
+
+ .note-custom-color {
+ cursor: pointer;
+ border-radius: 5px;
+
+ &:hover {
+ background-color: gray('400');
+ color: black
+ }
+ }
+ }
+
+ .dropdown-toggle:after {
+ display: none;
+ }
+
+ .note-palette:first-child {
+ width: 100%;
+
+ .note-color-reset,
+ .note-custom-color {
+ width: 162px;
+ }
+
+ .note-color-reset {
+ padding: 0 3px !important;
+ height: 32px;
+ }
+
+ .note-holder,
+ .note-color-palette .note-color-row,
+ .note-custom-color-palette .note-color-row {
+ height: auto !important;
+
+ &::after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+
+ .note-color-btn {
+ float: left;
+ height: 20px;
+ width: 20px;
+ margin: 1px 1px 0 0;
+ border: 1px solid black;
+ cursor: pointer;
+ @include o-alpha-button-preview;
+
+ &.o_small {
+ width: 16px;
+ height: 16px;
+ margin: 3px 3px 2px 2px;
+ border-radius: 50%;
+ }
+
+ &.o_clear {
+ clear: both;
+ }
+
+ &.o_btn_transparent {
+ display: none;
+ }
+ }
+ }
+ }
+ }
+}
+
+.note-popover.popover.note-hint-popover .popover-content {
+ height: auto;
+}
+
+.note-popover,
+.note-editor {
+ .modal .modal-footer p {
+ display: none;
+ }
+}
+
+.note-handle {
+ pointer-events: none;
+
+ .note-control-selection {
+ z-index: 1;
+ overflow: hidden;
+
+ .note-control-se {
+ border-top: 0;
+ border-left: 0;
+ background: transparent;
+ }
+ }
+}
+
+.o_fake_editable:not([data-oe-model]):focus {
+ outline: none;
+}
+
+.note-editor.dragover .note-dropzone {
+ z-index: $o-wysiwyg-zindex + 1;
+}
+
+// MODALS
+
+body .modal {
+
+ // SELECT MEDIA
+ .o_select_media_dialog {
+ max-width: 80%;
+
+ .modal-body {
+ .tab-pane {
+ min-height: 300px;
+ }
+
+ .existing-attachments {
+ width: 100%;
+ }
+
+ .o_existing_attachment_cell {
+ $o-border-image: 2px;
+
+ .o_existing_attachment_remove {
+ @include o-position-absolute($o-border-image, 15px + $o-border-image);
+ padding: 3px 5px 5px 5px;
+ background-color: rgba(white, 0.8);
+ text-shadow: 0 0 1px white;
+ border-radius: 0 0 0 2px;
+ opacity: 0;
+ cursor: pointer;
+
+ &:hover {
+ background-color: white;
+ color: $o-wysiwyg-color-danger;
+ }
+ }
+
+ .o_image {
+ border: $o-border-image solid transparent;
+ cursor: pointer;
+ transition: opacity 400ms ease 0s;
+
+ &.o_webimage {
+ width: 100%;
+ height: auto;
+ background-image: none !important;
+ }
+
+ &.o_image_loading {
+ opacity: 0;
+ }
+ }
+
+ &.o_selected .o_image {
+ border-color: $o-brand-odoo;
+ box-shadow: 0px 0px 2px 2px $o-brand-secondary;
+ }
+
+ &:hover .o_existing_attachment_remove {
+ opacity: 1;
+ }
+ }
+
+ .font-icons-icons {
+ >span {
+ text-align: center;
+ font-size: 22px;
+ margin: 5px;
+ width: 50px;
+ height: 50px;
+ padding: 15px;
+ cursor: pointer;
+
+ &.o_selected {
+ cursor: auto;
+ background-color: $o-wysiwyg-bg-color-dark;
+ box-shadow: 0px 0px 0px 1px $o-wysiwyg-color-success;
+ }
+ }
+ }
+
+ #editor-media-image,
+ #editor-media-document {
+ .o_we_url_input {
+ width: 300px;
+ }
+ }
+
+ // VIDEO TAB
+ #editor-media-video {
+ .o_video_dialog_form {
+ #o_video_form_group {
+ position: relative;
+ width: 100%;
+
+ >textarea {
+ width: 100%;
+ min-height: 95px;
+ padding-bottom: 25px;
+ overflow-y: scroll;
+ }
+
+ .o_validate_feedback {
+ @include o-position-absolute(auto, 10px, 10px);
+
+ >.fa {
+ display: none;
+ }
+ }
+
+ &.o_has_error .o_validate_feedback>.fa-exclamation-triangle {
+ display: block;
+ }
+
+ &.o_has_success .o_validate_feedback>.fa-check {
+ display: block;
+ }
+ }
+ }
+
+ #video-preview {
+ position: relative;
+ @include o-we-preview-box();
+ border: none;
+
+ .media_iframe_video {
+ width: 100%;
+ }
+
+ .o_video_dialog_iframe {
+ @include o-we-preview-content;
+ max-width: 100%;
+ max-height: 100%;
+
+ &.alert {
+ animation: fadeInDownSmall 700ms forwards;
+ margin: 0 auto;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Crop Dialog
+ .o_crop_image_dialog .o_crop_area {
+ height: 350px;
+
+ .cropper-point {
+ background-color: white;
+ transition: all 400ms $o-wysiwyg-md-ease 0s;
+ transform: scale(1.5, 1.5);
+ border-radius: 10px;
+ box-shadow: 0 0 1px 1px rgba(23, 23, 23, 0.5);
+ opacity: 1;
+
+ &:hover {
+ transform: scale(2.5, 2.5);
+ }
+ }
+
+ .cropper-line {
+ background: black;
+ }
+
+ .cropper-view-box {
+ outline: 2px solid white;
+ }
+ }
+
+ // LINK EDITOR DIALOG COLOR SELECTOR
+ .o_link_dialog {
+ .o_link_dialog_color>.o_link_dialog_color_item {
+ position: relative;
+ width: 30px;
+ height: 30px;
+ padding: 0; // Important for themes
+ border: 2px solid rgba(0, 0, 0, 0.25);
+ border-radius: 50%;
+
+ input:checked+i::before {
+ content: "\f00c";
+ @include o-position-absolute($top: 6px, $left: 5px);
+ }
+
+ &.btn-link {
+ padding: 3px 9px;
+
+ >span {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .o_link_dialog_preview {
+ border-left: 1px solid gray('200');
+ }
+ }
+}
+
+// Highlight selected image/icon
+%o-we-selected-image {
+ $o-selected-image-color: rgba(150, 150, 220, 0.3);
+ background: $o-selected-image-color;
+ outline: 3px solid $o-selected-image-color;
+}
+
+img.o_we_selected_image {
+ @extend %o-we-selected-image;
+}
+
+.fa.o_we_selected_image::before {
+ @extend %o-we-selected-image;
+}
+
+.fa.mx-auto,
+img.o_we_custom_image.mx-auto {
+ display: block;
+}
+
+// ENTER IN EDIT MODE
+
+.note-editor .css_editable_mode_display {
+ border: 1px dashed rgba(153, 153, 153, 0.5);
+ display: block !important;
+}
+
+.note-editable {
+
+ li,
+ p,
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ &:empty:after {
+ content: "\00a0"
+ }
+ }
+}
+
+.wysiwyg_iframe {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
diff --git a/addons/web_editor/static/src/scss/wysiwyg_iframe.scss b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss
new file mode 100644
index 00000000000..d019fdce753
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss
@@ -0,0 +1,24 @@
+iframe.wysiwyg_iframe.o_fullscreen {
+ position: fixed !important;
+ left: 0 !important;
+ right: 0 !important;
+ top: 0 !important;
+ bottom: 0 !important;
+ width: 100% !important;
+ min-height: 100% !important;
+ z-index: 1001 !important;
+ border: 0;
+}
+
+body.o_in_iframe {
+ background-color: $o-view-background-color;
+ overflow: hidden;
+
+ .note-editable {
+ height: calc(100vh - #{$o-wysiwyg-toolbar-height} - 2px);
+ }
+
+ .note-statusbar {
+ display: none;
+ }
+}
diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
new file mode 100644
index 00000000000..b693fd38544
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
@@ -0,0 +1,872 @@
+///
+/// This file contains all variables and mixins that are specific to the editor.
+///
+
+$o-wysiwyg-handles-accent-color: #00e2ff !default;
+$o-wysiwyg-handle-edge-size: 1px !default;
+$o-wysiwyg-dropzone-size: 40px !default;
+$o-wysiwyg-sidebar-width: 210px !default;
+$o-wysiwyg-sidebar-height: 36px !default;
+$o-wysiwyg-overlay-option-size: 25px !default;
+$o-wysiwyg-handles-btn-size: 18px !default;
+$o-wysiwyg-handle-border-width: 1px !default;
+
+$o-wysiwyg-handles-offset-to-hide: 10000px !default;
+
+
+// NOTE EDITOR
+.note-editor.o_snippets_loaded {
+ transition: padding-left 400ms $o-wysiwyg-md-ease 0s;
+ padding-left: 210px !important;
+}
+
+.note-editor.note-frame .note-editing-area .o_wrap_editable_snippets {
+ overflow: auto;
+
+ .note-editable {
+ overflow: visible;
+ }
+}
+
+.o_in_iframe .note-editor.o_snippets_loaded .note-editable {
+ padding-top: 30px;
+}
+
+// SNIPPET PANEL
+#oe_snippets {
+ @include o-w-preserve-btn;
+
+ display: flex;
+ flex-flow: column nowrap;
+ @include o-position-absolute(0, auto, 0, -$o-wysiwyg-sidebar-width);
+ position: absolute;
+ width: $o-wysiwyg-sidebar-width;
+ z-index: $o-wysiwyg-zindex - 1;
+
+ font-family: Roboto, $font-family-sans-serif;
+ border-right: 1px solid #aaa;
+ transition: left 400ms $o-wysiwyg-md-ease 0s;
+ background-image: linear-gradient(45deg, $o-wysiwyg-bg-color, darken($o-wysiwyg-bg-color, 10%));
+ box-shadow: 0px 10px 10px -10px black inset;
+ border: 1px solid $o-wysiwyg-border-color;
+
+ #snippets_menu {
+ flex: 0 0 auto;
+ height: $o-wysiwyg-sidebar-height;
+ line-height: $o-wysiwyg-sidebar-height;
+ margin: 0;
+ padding: 0;
+ background: $o-wysiwyg-bg-color;
+ text-align: center;
+ font-weight: normal;
+ font-size: 17px;
+ color: $o-wysiwyg-color;
+ font-family: $o-wysiwyg-font-family;
+ }
+
+ #o_scroll {
+ height: 100%;
+ overflow: auto;
+
+ .o_panel_header {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 3%;
+ color: $o-wysiwyg-color;
+ margin-bottom: 3%;
+
+ i {
+ margin-right: 5px;
+ }
+ }
+
+ .o_panel_body {
+ &:after {
+ content: " ";
+ display: table;
+ clear: both;
+ }
+
+ .oe_snippet {
+ float: left;
+ width: 45.5%;
+ margin: 0 0 6% 3%;
+ box-shadow: none;
+ background-color: transparent;
+ user-select: none;
+
+ cursor: pointer;
+ cursor: copy;
+ cursor: grab;
+
+ .oe_snippet_thumbnail {
+ .oe_snippet_thumbnail_img {
+ border: none;
+ }
+
+ .oe_snippet_thumbnail_title {
+ border: none;
+ padding: 5px 0;
+ }
+
+ &:hover .oe_snippet_thumbnail_img {
+ transition: transform 200ms ease 0s;
+ transform: scale(1.05);
+ }
+ }
+
+ &.o_disabled {
+ .oe_snippet_thumbnail {
+ background-color: rgba(255, 0, 0, 0.75);
+ }
+
+ .oe_snippet_thumbnail_img {
+ opacity: 0.8;
+ }
+ }
+
+ &.o_snippet_install {
+ .oe_snippet_thumbnail_img {
+ opacity: 0.5;
+ }
+
+ .btn.o_install_btn {
+ display: none;
+ @include o-position-absolute(16px, 4px, auto, 4px);
+ }
+
+ &:hover .btn.o_install_btn {
+ display: block;
+ }
+ }
+
+ &:nth-child(2n+1) {
+ clear: left;
+ }
+ }
+ }
+ }
+
+ &.o_loaded {
+ left: 0 !important;
+ }
+}
+
+.oe_snippet {
+ // No root because can be drag and drop (and the helper is in the body)
+ position: relative;
+ width: 100px;
+ background-color: $o-wysiwyg-bg-color;
+ z-index: $o-wysiwyg-zindex;
+
+ > :not(.oe_snippet_thumbnail) {
+ display: none !important;
+ }
+
+ .oe_snippet_thumbnail {
+ width: 100%;
+
+ .oe_snippet_thumbnail_img {
+ width: 100%;
+ height: 0;
+ border: 1px solid $o-wysiwyg-border-color;
+ padding-bottom: 75%;
+ background-size: cover;
+ background-position: center center;
+ text-align: center;
+ overflow: hidden;
+ }
+
+ img.oe_snippet_thumbnail_img {
+ height: auto;
+ padding-bottom: 0;
+ }
+
+ .oe_snippet_thumbnail_title {
+ display: block;
+ border: 1px solid $o-wysiwyg-bg-color;
+ padding: 5px;
+ font-size: 12px;
+ font-weight: 300;
+ text-shadow: none;
+ color: $o-wysiwyg-color;
+ }
+
+ &:hover .oe_snippet_thumbnail_title {
+ color: $o-wysiwyg-color-light;
+ }
+ }
+}
+
+// SNIPPET OPTIONS
+.colorpicker {
+ .o_colorpicker_sections {
+ font-size: 14px;
+
+ .o_colorpicker_section_tabs {
+ width: 225px;
+ height: 100%;
+ }
+
+ button {
+ float: left;
+ height: 25px;
+ width: 25px;
+ margin: 1px 1px 0 2px;
+ border: 1px solid black;
+ }
+
+ div[data-group="transparent_grayscale"] button {
+ @include o-alpha-button-preview;
+
+ &.o_small {
+ width: 23px;
+ height: 23px;
+ margin: 2px;
+ border-radius: 50%;
+ }
+
+ &:hover,
+ &.selected {
+ box-shadow: 0px 0px 2px 2px $o-wysiwyg-color;
+ }
+
+ &.selected:before {
+ content: "\f00c";
+ font-family: "FontAwesome";
+ padding-top: 1px;
+ color: theme-color('success');
+ }
+
+ &[data-event="foreColor"] {
+ background-color: $o-wysiwyg-bg-color;
+
+ &:before {
+ background-color: rgba(white, 0.3);
+ }
+ }
+
+ &.o_btn_transparent::before {
+ background-color: transparent;
+ }
+ }
+
+ .note-color-palette>div {
+ clear: both;
+ }
+
+ .note-color-row {
+ height: 28px;
+ }
+ }
+
+ .note-palette-title {
+ padding: 10px 0px 10px 10px;
+ color: $o-wysiwyg-color-light;
+ font-weight: bold;
+ }
+
+ .palette-reset {
+ @include o-position-absolute(0, 0);
+ margin: 0;
+ padding: 5px 10px 0;
+
+ .note-color-reset {
+ font-size: 20px !important;
+ color: desaturate(rgba($o-wysiwyg-color-danger, 0.6), 40%);
+ margin: 1px 0 0;
+ padding: 0;
+ cursor: pointer;
+
+ &:hover {
+ background: transparent !important;
+ color: $o-wysiwyg-color-danger;
+ }
+ }
+ }
+}
+
+// DROPZONES
+@keyframes dropZoneInsert {
+ to {
+ background-color: rgba($o-brand-odoo, 0.2);
+ }
+}
+
+.note-editable .oe_drop_zone {
+ background-color: rgba($o-brand-odoo, 0.05);
+ animation: dropZoneInsert 1s linear 0s infinite alternate;
+
+ &.oe_insert {
+ z-index: $o-wysiwyg-zindex - 1;
+
+ border: 2px dashed $o-wysiwyg-border-color;
+ border-top: none;
+ border-bottom: none;
+
+ position: relative;
+ width: 100%;
+ height: $o-wysiwyg-dropzone-size;
+ margin: (-$o-wysiwyg-dropzone-size/2) 0;
+
+ &:after {
+ content: "";
+ display: block;
+ width: 100%;
+ height: 50%;
+ border-bottom: 2px dashed $o-wysiwyg-border-color;
+ box-sizing: content-box;
+ }
+
+ &.oe_vertical {
+ border: 2px dashed $o-brand-odoo;
+ border-left: none;
+ border-right: none;
+
+ width: $o-wysiwyg-dropzone-size;
+ float: left;
+ margin: 0 (-$o-wysiwyg-dropzone-size/2);
+
+ &:after {
+ width: 50%;
+ height: 100%;
+ border-bottom: none;
+ border-right: 2px dashed $o-wysiwyg-border-color;
+ }
+ }
+ }
+}
+
+// MANIPULATORS
+#oe_manipulators {
+ position: relative;
+
+ // SNIPPET MANIPULATORS
+ .oe_overlay {
+ @include o-position-absolute;
+ display: none;
+ height: 0;
+ background: transparent;
+ text-align: center;
+ transition: opacity 400ms linear 0s;
+
+ &.o_keypress {
+ opacity: 0;
+ }
+
+ &.oe_active {
+ display: block;
+ }
+
+ // OVERLAY OPTIONS
+ >.oe_overlay_options {
+ font-family: $o-wysiwyg-font-family;
+ @include o-position-absolute($bottom: 0, $left: 0);
+ width: 250px; // the parent oe_overlay has a width equal to the snippet width, the 250px is there to force the options to stay on one line without overriding bootstrap .btn-group
+ text-align: left;
+ z-index: $o-wysiwyg-zindex + 1;
+
+ >.btn-group {
+ white-space: nowrap;
+ border: 0;
+
+ .btn {
+ display: inline-block;
+ width: $o-wysiwyg-overlay-option-size;
+ height: $o-wysiwyg-overlay-option-size;
+ padding: 0;
+ border: 1px solid $o-wysiwyg-bg-color;
+ line-height: $o-wysiwyg-overlay-option-size;
+ font-size: 11px;
+ border-radius: 0;
+ font-weight: normal;
+
+ transition: all 400ms ease 0s;
+ color: $o-wysiwyg-color;
+ background-color: $o-wysiwyg-bg-color;
+
+ &:hover {
+ color: $o-wysiwyg-color-light;
+ background-color: lighten($o-wysiwyg-bg-color, 10%);
+ }
+
+ &.oe_snippet_remove {
+ color: $o-wysiwyg-color;
+ background-color: $o-wysiwyg-color-danger;
+ border-color: darken($o-wysiwyg-color-danger, 20%);
+
+ >.fa {
+ color: $o-wysiwyg-color;
+ }
+
+ &:hover {
+ background-color: darken($o-wysiwyg-color-danger, 20%);
+ }
+ }
+
+ &.oe_snippet_parent>i {
+ transform: scaleX(-1);
+ font-size: 12px;
+ }
+
+ >.fa {
+ color: $o-wysiwyg-color;
+ line-height: inherit;
+ vertical-align: top;
+ }
+ }
+
+ // CUSTOMIZE MENU BUTTON
+ >.oe_options {
+ .btn {
+ width: auto;
+ padding: 0 25px 0 5px;
+ background-color: $o-wysiwyg-bg-color;
+ text-transform: uppercase;
+
+ &:hover,
+ &:active,
+ &:focus {
+ background-color: lighten($o-wysiwyg-bg-color, 5%);
+ border-color: lighten($o-wysiwyg-bg-color, 10%);
+ }
+
+ &:before,
+ &:after {
+ content: "";
+ width: 7px;
+ height: 2px;
+ background-color: $o-wysiwyg-color-light;
+ @include o-position-absolute(46%);
+ transition: all 0.3s ease 0s;
+ }
+
+ &:before {
+ right: 9px;
+ transform: rotate(45deg);
+ }
+
+ &:after {
+ right: 5px;
+ transform: rotate(-45deg);
+ }
+ }
+
+ // Open menu
+ &.show .btn {
+
+ &:active,
+ &:focus,
+ &:active:focus {
+ box-shadow: none;
+ }
+
+ &:before,
+ &:after {
+ width: 9px;
+ }
+
+ &:before {
+ right: 10px;
+ transform: translateX(5px) rotate(-45deg);
+ }
+
+ &:after {
+ transform: rotate(45deg);
+ }
+ }
+
+ // CUSTOMIZE MENU
+ .dropdown-menu {
+ margin: -1px 0 0 0;
+ padding: 0;
+ border: 1px solid $o-wysiwyg-bg-color;
+ border-radius: 0;
+ background-color: $o-wysiwyg-bg-color;
+
+ .dropdown-item {
+ position: relative;
+ color: $o-wysiwyg-color-light;
+ font-weight: normal;
+ font-size: 12px;
+ padding: 5px 40px 5px 10px;
+
+ &:hover {
+ background: transparent;
+ cursor: pointer;
+ }
+
+ &.active {
+ &:before {
+ @include o-position-absolute($top: 5px, $right: 5px);
+ content: "\f00c";
+ font-family: "FontAwesome";
+ color: $o-brand-primary;
+ }
+ }
+
+ >i {
+ width: 15px;
+ margin-right: 10px;
+ text-align: center;
+ }
+ }
+
+ .dropdown-submenu {
+ position: relative;
+
+ &::before {
+ @include o-position-absolute($top: 12px, $right: 10px);
+ @include o-caret-right(4px);
+ border-left-color: $o-wysiwyg-color-light;
+ }
+
+ &:hover>.dropdown-menu {
+ display: block;
+ left: 100%;
+ top: 0;
+
+ &.o_open_to_left {
+ left: auto;
+ right: 100%;
+ }
+ }
+ }
+
+ .dropdown-divider {
+ display: block;
+ height: 1px;
+ border-top: 1px solid #666;
+ margin: 0;
+ }
+
+ .dropdown-header {
+ color: $o-wysiwyg-color-light;
+ font-size: 11px;
+ margin-top: 2px;
+ text-transform: uppercase;
+
+ &:hover {
+ background-color: initial;
+ }
+
+ &.o_main_header {
+ padding-left: 10px;
+ }
+
+ &.o_parent_editor_header {
+ position: relative;
+ color: $o-wysiwyg-color-light;
+ font-weight: 400;
+ font-size: 10px;
+ margin: 10px 0 0 10px;
+ text-transform: uppercase;
+ padding-bottom: .25em;
+
+ &::before {
+ content: "\f148";
+ @include o-position-absolute($top: 5px, $left: 4px);
+ font-family: FontAwesome;
+ transform: scaleX(-1);
+ }
+
+ ~.dropdown-submenu .dropdown-item,
+ ~.dropdown-item {
+ font-size: 11px;
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ ~.dropdown-submenu::before {
+ @include o-position-absolute($top: 10px, $right: 10px);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ &.o_top_cover>.oe_overlay_options {
+ bottom: auto;
+ top: 1px;
+ right: 1px;
+ }
+
+ // HANDLES
+ >.o_handles {
+ @include o-position-absolute(-$o-wysiwyg-handles-offset-to-hide, 0, auto, 0);
+ z-index: $o-wysiwyg-zindex;
+
+ &:hover>.o_handle {
+ background-color: rgba($o-wysiwyg-handles-accent-color, 0.05);
+ }
+
+ >.o_handle {
+
+ position: relative;
+ border: 0 solid $o-wysiwyg-handles-accent-color;
+ transition: background 300ms ease 0s;
+
+ &:hover,
+ &.o_active {
+ background-color: rgba($o-wysiwyg-handles-accent-color, 0.2);
+
+ &:before {
+ content: '';
+ @include o-position-absolute(0, $left: 50%);
+ width: 1px;
+ height: 100%;
+ margin-left: -1px;
+ background-color: rgba($o-wysiwyg-handles-accent-color, 0.5);
+ }
+
+ &.w:before {
+ @include o-position-absolute(50%, $left: 0);
+ width: 100%;
+ height: 1px;
+ }
+
+ &:after {
+ border-color: darken($o-wysiwyg-handles-accent-color, 10%);
+ background-color: darken($o-wysiwyg-handles-accent-color, 20%);
+ }
+ }
+
+ &:after {
+ display: block;
+ width: $o-wysiwyg-handles-btn-size;
+ height: $o-wysiwyg-handles-btn-size;
+ border: solid 1px darken($o-wysiwyg-handles-accent-color, 10%);
+ line-height: $o-wysiwyg-handles-btn-size - 2;
+ font-size: 14px;
+ font-family: FontAwesome;
+ background-color: darken($o-wysiwyg-handles-accent-color, 20%);
+ color: white;
+ }
+
+ &.w:after,
+ &.e:after {
+ content: "\f07e";
+ }
+
+ &.s:after,
+ &.n:after {
+ content: "\f07d";
+ }
+
+ &.o_handle_start {
+
+ &.w:after,
+ &.e:after {
+ content: '\f061';
+ }
+
+ &.n:after,
+ &.s:after {
+ content: '\f063';
+ }
+ }
+
+ &.o_handle_end {
+
+ &.w:after,
+ &.e:after {
+ content: '\f060';
+ }
+
+ &.n:after,
+ &.s:after {
+ content: '\f062';
+ }
+ }
+
+ &.w {
+ @include o-position-absolute($o-wysiwyg-handles-offset-to-hide, auto, -$o-wysiwyg-handles-offset-to-hide, 0);
+ width: $o-wysiwyg-handle-edge-size;
+ border-width: $o-wysiwyg-handle-border-width;
+ border-right-width: 0;
+ cursor: e-resize;
+
+ &:after {
+ @include o-position-absolute($top: 50%, $left: 40%);
+ margin-top: -$o-wysiwyg-handles-btn-size/2;
+ }
+ }
+
+ &.e {
+ @include o-position-absolute($o-wysiwyg-handles-offset-to-hide, 0, -$o-wysiwyg-handles-offset-to-hide, auto);
+ width: $o-wysiwyg-handle-edge-size;
+ border-right-width: $o-wysiwyg-handle-border-width;
+ cursor: w-resize;
+
+ &:after {
+ @include o-position-absolute($top: 50%, $right: 40%);
+ margin-top: -$o-wysiwyg-handles-btn-size/2;
+ }
+ }
+
+ &.n {
+ @include o-position-absolute($o-wysiwyg-handles-offset-to-hide, 0, auto, 0);
+ border-top-width: $o-wysiwyg-handle-border-width;
+ cursor: ns-resize;
+
+ &:after {
+ @include o-position-absolute($left: 50%, $top: 40%);
+ margin-left: -$o-wysiwyg-handles-btn-size/2;
+ }
+ }
+
+ &.s {
+ @include o-position-absolute(auto, 0, -$o-wysiwyg-handles-offset-to-hide, 0);
+ border-bottom-width: $o-wysiwyg-handle-border-width;
+ cursor: ns-resize;
+
+ &:after {
+ @include o-position-absolute($left: 50%, $bottom: 40%);
+ margin-left: -$o-wysiwyg-handles-btn-size/2;
+ }
+ }
+
+ &.readonly {
+ cursor: auto !important;
+
+ &:after {
+ display: none !important;
+ }
+
+ &:hover {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+
+ .contact_menu {
+ display: block;
+ top: -24px;
+ margin: 0px;
+ padding: 2px 0px;
+ position: relative;
+ min-height: 44px;
+ position: absolute !important;
+ }
+}
+
+.s-resize-important * {
+ cursor: s-resize !important;
+}
+
+.n-resize-important * {
+ cursor: n-resize !important;
+}
+
+.e-resize-important * {
+ cursor: e-resize !important;
+}
+
+.w-resize-important * {
+ cursor: w-resize !important;
+}
+
+.move-important * {
+ cursor: move !important;
+}
+
+.dropdown-menu label .o_switch {
+ margin: 0;
+ padding: 2px 0;
+}
+
+.text-input-group {
+ position: relative;
+ margin-bottom: 45px;
+
+ input {
+ font-size: 18px;
+ padding: 10px 10px 10px 5px;
+ display: block;
+ width: 300px;
+ border: none;
+ border-bottom: 1px solid #757575;
+ }
+
+ input:focus {
+ outline: none;
+ }
+
+ /* LABEL ======================================= */
+ label {
+ color: #999;
+ font-size: 18px;
+ font-weight: normal;
+ @include o-position-absolute($top: 10px, $left: 5px);
+ pointer-events: none;
+ transition: 0.2s ease all;
+ }
+
+ /* active state */
+ input:focus~label,
+ input:valid~label {
+ top: -20px;
+ font-size: 14px;
+ color: #5264AE;
+ }
+
+ /* BOTTOM BARS ================================= */
+ .bar {
+ position: relative;
+ display: block;
+ width: 300px;
+ }
+
+ .bar:before,
+ .bar:after {
+ content: '';
+ height: 2px;
+ width: 0;
+ bottom: 1px;
+ @include o-position-absolute;
+ background: #5264AE;
+ transition: 0.2s ease all;
+ }
+
+ .bar:before {
+ left: 50%;
+ }
+
+ .bar:after {
+ right: 50%;
+ }
+
+ /* active state */
+ input:focus~.bar:before,
+ input:focus~.bar:after {
+ width: 50%;
+ }
+
+ /* HIGHLIGHTER ================================== */
+ .highlight {
+ @include o-position-absolute($top: 25%, $left: 0);
+ height: 60%;
+ width: 100px;
+ pointer-events: none;
+ opacity: 0.5;
+ }
+
+ /* active state */
+ input:focus~.highlight {
+ animation: inputHighlighter 0.3s ease;
+ }
+}
+
+// DRAG&DROP ANIMATIONS
+.oe_snippet_body {
+ opacity: 0;
+ animation: fadeInDownSmall 700ms forwards;
+}
diff --git a/addons/web_editor/static/src/scss/wysiwyg_variables.scss b/addons/web_editor/static/src/scss/wysiwyg_variables.scss
new file mode 100644
index 00000000000..2beafe71681
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg_variables.scss
@@ -0,0 +1,42 @@
+// Mixins for buttons with alpha bg-color
+@mixin o-alpha-button-preview {
+ position: relative;
+ z-index: 1;
+
+ // Place an overlay that inherit the button's bg-color.
+ &::before {
+ content: "";
+ @include o-position-absolute(0, 0, 0, 0);
+ background-color: inherit;
+ }
+
+ &::after {
+ content: "";
+ @include o-position-absolute(0, 0, 0, 0);
+ z-index: -1;
+ background-image: url('/web_editor/static/src/img/transparent.png');
+ background-size: 10px auto;
+ }
+}
+
+$o-wysiwyg-zindex: 1000;
+
+$o-wysiwyg-md-ease: cubic-bezier(0.19, 1, 0.22, 1) !default;
+
+$o-wysiwyg-font-family: Roboto,
+"Montserrat",
+"Segoe UI",
+"Helvetica Neue",
+Helvetica,
+Arial,
+sans-serif;
+
+$o-wysiwyg-toolbar-height: 32px;
+
+$o-wysiwyg-color: #212629 !default;
+$o-wysiwyg-color-light: #616669 !default;
+$o-wysiwyg-color-danger: #912629 !default;
+$o-wysiwyg-color-success: #219629 !default;
+$o-wysiwyg-bg-color: #f7f7f7 !default;
+$o-wysiwyg-bg-color-dark: #a7a7a7 !default;
+$o-wysiwyg-border-color: #a9a9a9 !default;
diff --git a/addons/web_editor/static/src/xml/backend.xml b/addons/web_editor/static/src/xml/backend.xml
index 146d96fe1e5..904f21f440c 100644
--- a/addons/web_editor/static/src/xml/backend.xml
+++ b/addons/web_editor/static/src/xml/backend.xml
@@ -1,12 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
- <t t-name="web_editor.FieldTextHtml">
- <div class="oe_form_field oe_form_field_html oe_form_embedded_html" t-att-style="widget.attrs.style">
- <iframe/>
- </div>
- </t>
-
<t t-name="web_editor.FieldTextHtml.button.translate">
<div class="btn-group float-right">
<button t-if="widget.field.translate" class="o_field_translate btn btn-secondary" style="height: 24px; padding: 1px 17px 0px 5px" aria-label="Translate" title="Translate">
@@ -15,12 +9,4 @@
</div>
</t>
- <t t-name="web_editor.FieldTextHtml.fullscreen">
- <span style="margin: 5px; position: fixed; top: 0; right: 0; z-index: 2000;">
- <button class="o_fullscreen btn btn-primary" style="width: 24px; height: 24px; background-color: #337ab7; border: 1px solid #2e6da4; border-radius: 4px; padding: 0; position: relative;">
- <img src="/web_editor/font_to_img/61541/rgb(255,255,255)/16" style="position: absolute; top: 3px; left: 4px;" alt="Fullscreen"/>
- </button>
- </span>
- </t>
-
</templates>
diff --git a/addons/web_editor/static/src/xml/editor.xml b/addons/web_editor/static/src/xml/editor.xml
index 05e7d584337..51c3d5412ff 100644
--- a/addons/web_editor/static/src/xml/editor.xml
+++ b/addons/web_editor/static/src/xml/editor.xml
@@ -1,378 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<templates id="template" xml:space="preserve">
<!--=================-->
- <!-- Base components -->
- <!--=================-->
-
- <!-- Editor top bar which contains the summernote tools and save/discard buttons -->
- <t t-name="web_editor.editorbar">
- <div id="web_editor-top-edit">
- <div id="web_editor-toolbars"/>
- <form class="navbar-form text-muted">
- <button type="button" class="btn btn-secondary" data-action="cancel"><i class="fa fa-times"/> Discard</button>
- <button type="button" class="btn btn-primary" data-action="save"><i class="fa fa-floppy-o"/> Save</button>
- </form>
- </div>
- </t>
-
- <!-- Custom checkbox (material-design-like toggle) -->
- <t t-name="web_editor.components.switch">
- <label class="o_switch" t-att-for="id">
- <input type="checkbox" t-att-id="id"/>
- <span/>
- <t t-if="label"><t t-esc="label"/></t>
- </label>
- </t>
-
- <!--=================-->
- <!-- Edition Dialogs -->
- <!--=================-->
-
- <!-- Alt Dialog (allows to change alt and title of page images) -->
- <form t-name="web_editor.dialog.alt" action="#">
- <div class="form-group row">
- <label class="col-md-3 col-form-label" for="alt"
- title="'Alt tag' specifies an alternate text for an image, if the image cannot be displayed (slow connection, missing image, screen reader ...).">
- Description <small>(ALT Tag)</small>
- </label>
- <div class="col-md-8">
- <input class="form-control" id="alt" required="required" t-att-value="widget.alt" type="text"/>
- </div>
- </div>
- <div class="form-group row">
- <label class="col-md-3 col-form-label" for="title"
- title="'Title tag' is shown as a tooltip when you hover the picture.">
- Tooltip <small>(TITLE Tag)</small>
- </label>
- <div class="col-md-8">
- <input class="form-control" id="title" required="required" t-att-value="widget.tag_title" type="text"/>
- </div>
- </div>
- </form>
-
- <!-- Media Dialog (allows to choose an img/pictogram/video) -->
- <div t-name="web_editor.dialog.media">
- <ul class="nav nav-tabs" role="tablist">
- <li class="nav-item"><a role="tab" href="#editor-media-image" aria-controls="editor-media-image" data-toggle="tab" t-attf-class="nav-link#{widget.noImages ? ' d-none' : ''}">Image</a></li>
- <li class="nav-item"><a role="tab" href="#editor-media-document" aria-controls="editor-media-document" data-toggle="tab" t-attf-class="nav-link#{widget.noDocuments ? ' d-none' : ''}">Document</a></li>
- <li class="nav-item"><a role="tab" href="#editor-media-icon" aria-controls="editor-media-icon" data-toggle="tab" t-attf-class="nav-link#{widget.noIcons ? ' d-none' : ''}">Pictogram</a></li>
- <li class="nav-item"><a role="tab" href="#editor-media-video" aria-controls="editor-media-video" data-toggle="tab" t-attf-class="nav-link#{widget.noVideos ? ' d-none' : ''}">Video</a></li>
- </ul>
- <!-- Tab panes -->
- <div class="tab-content">
- <div role="tabpanel" class="tab-pane fade" id="editor-media-image"/>
- <div role="tabpanel" class="tab-pane fade" id="editor-media-document"/>
- <div role="tabpanel" class="tab-pane fade" id="editor-media-icon"/>
- <div role="tabpanel" class="tab-pane fade" id="editor-media-video"/>
- </div>
- </div>
- <t t-name="web_editor.dialog.media.search">
- <div class="input-group ml-auto">
- <input type="text" class="form-control o_we_search" t-att-placeholder="searchPlaceholder.trim()"/>
- <div class="input-group-append">
- <div class="input-group-text o_we_search_icon">
- <i class="fa fa-search" title="Search" role="img" aria-label="Search"/>
- </div>
- </div>
- </div>
- </t>
-
- <!-- Image choosing part of the Media Dialog -->
- <t t-name="web_editor.dialog.image">
- <t t-set="iframeName" t-value="_.uniqueId('fileframe-')"/>
- <form method="POST"
- action="/web_editor/attachment/add"
- enctype="multipart/form-data"
- t-att-target="iframeName">
- <input type="hidden" name="csrf_token" t-att-value="csrf_token"/>
- <input type="hidden" name="filters" t-att-value="widget.firstFilters.join('_')"/>
- <input t-if="widget.options.res_id" type="hidden" name="res_id" t-att-value="widget.options.res_id"/>
- <input t-if="widget.options.res_model" type="hidden" name="res_model" t-att-value="widget.options.res_model"/>
- <t t-call="web_editor.dialog.files.submenu">
- <t t-set="isDocument" t-value="widget.options.document"/>
- <t t-set="accept" t-value="widget.accept"/>
- </t>
- <div class="form-text"/>
- <div class="existing-attachments"/>
- <div class="mt-4 text-center mx-auto">
- <button class="btn btn-primary o_load_more" type="button">Load more...</button>
- <div class="mt-4 o_load_done_msg d-none">
- <span><i>All attachments have been loaded</i></span>
- </div>
- </div>
- <iframe class="o_file_upload_iframe d-none" t-att-name="iframeName"/>
- </form>
- </t>
- <t t-name="web_editor.dialog.files.submenu">
- <div class="form-inline align-items-center py-4">
- <input type="file" class="d-none o_file_input" name="upload" t-att-accept="accept" multiple="multiple"/>
- <input type="hidden" name="disable_optimization" value=""/>
-
- <div class="btn-group">
- <button type="button" class="btn btn-primary o_upload_media_button">
- <t t-if="isDocument">Upload a document</t>
- <t t-else="">Upload an image</t>
- </button>
- <t t-if="!isDocument">
- <button class="btn btn-primary dropdown-toggle dropdown-toggle-split o_media_optimization" data-toggle="dropdown" type="button">
- <span class="sr-only">Alternate Upload</span>
- </button>
- <div class="dropdown-menu" role="menu">
- <button type="button" role="menuitem" class="dropdown-item o_upload_media_button_no_optimization">Upload image without optimization</button>
- </div>
- </t>
- </div>
-
- <div class="input-group align-items-center ml-2">
- <input type="text" class="form-control o_we_url_input" name="url" t-attf-placeholder="https://www.odoo.com/#{isDocument ? 'mydocument' : 'logo.png'}"/>
- <div class="input-group-append align-items-center">
- <button type="button" class="btn btn-secondary o_upload_media_url_button" disabled="disabled">
- <t t-if="isDocument">Add document</t>
- <t t-else="">Add image</t>
- </button>
- <div class="ml-2">
- <span class="o_we_url_success text-success d-none fa fa-lg fa-check" title="The URL seems valid"/>
- <span class="o_we_url_warning text-warning d-none fa fa-lg fa-warning" title="The URL does not contain any image. The file will be added in the document section."/>
- <span class="o_we_url_error text-danger d-none fa fa-lg fa-times" title="The URL does not seem to work"/>
- </div>
- </div>
- </div>
-
- <t t-call="web_editor.dialog.media.search">
- <t t-set="searchPlaceholder">
- <t t-if="isDocument">Search a document</t>
- <t t-else="">Search an image</t>
- </t>
- </t>
- </div>
- </t>
- <t t-name="web_editor.dialog.files.existing.content">
- <div class="existing-attachments">
- <div t-foreach="rows" t-as="row" class="row mt-2">
- <div t-foreach="row" t-as="attachment" class="col-2 o_existing_attachment_cell">
- <i t-if="attachment.res_model === 'ir.ui.view'" class="fa fa-trash o_existing_attachment_remove" title="This file is a public view attachment" role="img" aria-label="This file is a public view attachment" t-att-data-id="attachment.id"/>
- <i t-else="" class="fa fa-trash o_existing_attachment_remove" title="This file is attached to the current record" role="img" aria-label="This file is attached to the current record" t-att-data-id="attachment.id"/>
- <div class="o_attachment_border" t-att-style="attachment.res_model === 'ir.ui.view' ? null : 'border: 1px solid #5cb85c;'"><div t-att-data-src="attachment.src" t-att-data-url="attachment.url" role="img" t-att-aria-label="attachment.name" t-att-title="attachment.name" t-att-data-id="attachment.id" t-att-data-mimetype="attachment.mimetype" t-attf-class="o_image #{withEffect ? 'o_image_loading' : ''}"/></div>
- <t t-if="isDocument">
- <small class="o_file_name"><t t-esc="attachment.name"/></small>
- </t>
- </div>
- </div>
- </div>
- </t>
- <t t-name="web_editor.dialog.image.existing.error">
- <div class="form-text">
- <p>The image could not be deleted because it is used in the
- following pages or views:</p>
- <ul t-as="view" t-foreach="views">
- <li>
- <a t-attf-href="/web#model=ir.ui.view&amp;id=#{view.id}">
- <t t-esc="view.name"/>
- </a>
- </li>
- </ul>
- </div>
- </t>
-
- <!-- Icon choosing part of the Media Dialog -->
- <t t-name="web_editor.dialog.font-icons">
- <form action="#">
- <div class="form-inline align-items-center py-4">
- <t t-call="web_editor.dialog.media.search">
- <t t-set="searchPlaceholder">Search a pictogram</t>
- </t>
- </div>
- <div class="font-icons-icons">
- <t t-call="web_editor.dialog.font-icons.icons">
- <t t-set="iconsParser" t-value="widget.iconsParser"/>
- </t>
- </div>
- </form>
- </t>
- <t t-name="web_editor.dialog.font-icons.icons">
- <t t-as="data" t-foreach="iconsParser">
- <span t-foreach="data.cssData" t-as="cssData"
- t-att-data-id="cssData.names[0]"
- t-att-title="cssData.names[0]"
- t-att-aria-label="cssData.names[0]" role="img"
- t-attf-class="font-icons-icon #{data.base} #{cssData.names[0]}"
- t-att-data-alias="cssData.names.join(',')"/>
- </t>
- </t>
-
- <!-- Video choosing part of the Media Dialog -->
- <t t-name="web_editor.dialog.video">
- <form action="#" class="row">
- <div class="col mt-4 o_video_dialog_form">
- <div class="form-group mb-2" id="o_video_form_group">
- <label class="col-form-label" for="o_video_text">
- Video code <small>(URL or Embed)</small>
- <div class="o_validate_feedback">
- <i class="fa fa-check text-success" role="img" aria-label="Checked" title="Checked"/>
- <i class="fa fa-exclamation-triangle text-danger" role="img" aria-label="Attention" title="Attention"/>
- </div>
- </label>
- <textarea class="form-control" id="o_video_text" placeholder="Copy-paste your URL or embed code here"/>
- </div>
- <div class="text-right">
- <small class="text-muted">Accepts <b><i>Youtube</i></b>, <b><i>Instagram</i></b>, <b><i>Vine.co</i></b>, <b><i>Vimeo</i></b>, <b><i>Dailymotion</i></b> and <b><i>Youku</i></b> videos</small>
- </div>
- <div class="o_video_dialog_options d-none mt-4">
- <div class="o_yt_option o_vim_option o_dm_option">
- <label class="o_switch mb0"><input id="o_video_autoplay" type="checkbox"/><span/>Autoplay</label>
- </div>
- <div class="o_yt_option o_vim_option">
- <label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label>
- </div>
- <div class="o_yt_option o_dm_option">
- <label class="o_switch mb0"><input id="o_video_hide_controls" type="checkbox"/><span/>Hide player controls</label>
- </div>
- <div class="o_yt_option">
- <label class="o_switch mb0"><input id="o_video_hide_fullscreen" type="checkbox"/><span/>Hide fullscreen button</label>
- </div>
- <div class="o_yt_option">
- <label class="o_switch mb0"><input id="o_video_hide_yt_logo" type="checkbox"/><span/>Hide Youtube logo</label>
- </div>
- <div class="o_dm_option">
- <label class="o_switch mb0"><input id="o_video_hide_dm_logo" type="checkbox"/><span/>Hide Dailymotion logo</label>
- </div>
- <div class="o_dm_option">
- <label class="o_switch mb0"><input id="o_video_hide_dm_share" type="checkbox"/><span/>Hide sharing button</label>
- </div>
- </div>
- </div>
- <div class="col-md-6">
- <div id="video-preview" class="p-3">
- <div class="o_video_dialog_preview_text small mb-2 d-none">Preview</div>
- <div class="media_iframe_video">
- <div class="media_iframe_video_size"/>
- <iframe class="o_video_dialog_iframe" allowfullscreen="allowfullscreen" frameborder="0" src=""/>
- </div>
- </div>
- </div>
- </form>
- </t>
-
- <!-- Link Dialog (allows to choose a style and content for a link on the page) -->
- <div t-name="web_editor.dialog.link" class="o_link_dialog">
- <div class="row">
- <form class="col-lg-8">
- <div t-attf-class="form-group row#{widget.needLabel ? '' : ' d-none'}">
- <label class="col-form-label col-md-3" for="o_link_dialog_label_input">Link Label</label>
- <div class="col-md-9">
- <input type="text" name="label" class="form-control" id="o_link_dialog_label_input" required="required" t-att-value="widget.data.text"/>
- </div>
- </div>
- <div class="form-group row o_url_input">
- <label class="col-form-label col-md-3" for="o_link_dialog_url_input">URL or Email</label>
- <div class="col-md-9">
- <input type="text" name="url" class="form-control" id="o_link_dialog_url_input" required="required"/>
- </div>
- </div>
- <div class="form-group row">
- <label class="col-form-label col-md-3">Size</label>
- <div class="col-md-9">
- <select name="link_style_size" class="form-control link-style">
- <option value="sm">Small</option>
- <option value="" selected="selected">Medium</option>
- <option value="lg">Large</option>
- </select>
- </div>
- </div>
- <div class="form-group row">
- <label class="col-form-label col-md-3">Style</label>
- <div class="col-md-9">
- <select name="link_style_shape" class="form-control link-style">
- <option value="" selected="selected">Default</option>
- <option value="outline">Outline</option>
- <option value="rounded-circle">Rounded</option>
- <option value="outline,rounded-circle">Outline-Rounded</option>
- <option value="flat">Flat</option>
- </select>
- </div>
- </div>
- <div class="form-group row">
- <label class="col-form-label col-md-3">Color</label>
- <div class="col-md-9">
- <div class="o_link_dialog_color">
- <label role="button" t-attf-class="o_link_dialog_color_item o_btn_preview btn btn-link text-center" data-color="">
- <span>L</span>
- <input class="d-none link-style" name="link_style_color" type="radio" value=""/>
- <i class="fa"/>
- </label>
- <t t-foreach="['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'primary', 'secondary', 'success', 'info', 'warning', 'danger']" t-as="color">
- <label role="button" t-attf-class="o_link_dialog_color_item o_btn_preview btn btn-#{color}" t-attf-aria-label="Color for #{color}" t-attf-title="Color for #{color}">
- <input type="radio" name="link_style_color" class="d-none link-style" t-att-value="color"/>
- <i class="fa"/>
- </label>
- </t>
- </div>
- </div>
- </div>
- <div class="form-group row">
- <div class="offset-md-3 col-md-9">
- <label class="o_switch">
- <input type="checkbox" name="is_new_window" t-att-checked="widget.data.isNewWindow ? 'checked' : undefined"/>
- <span/>
- Open in new window
- </label>
- </div>
- </div>
- </form>
- <div class="col-lg-4 o_link_dialog_preview">
- <div class="form-group text-center">
- <label>Preview</label>
- <div style="overflow: auto; max-width: 100%; max-height: 200px;">
- <a href="#" id="link-preview" aria-label="Preview" title="Preview"/>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- Crop Image Dialog (allows to crop image on the page) -->
- <div t-name="web_editor.dialog.crop_image" class="o_crop_image_dialog">
- <div t-if="widget.imageData.isExternalImage" class="alert alert-warning text-center" role="alert">
- <i class="fa fa-exclamation-triangle fa-2x"/>
- <h4>This image is an external image</h4>
- <p>
- This type of image is not supported for cropping.<br/>
- If you want to crop it, please first download it from the original source and upload it in Odoo.
- </p>
- </div>
- <t t-else="">
- <div class="o_crop_area">
- <img class="img img-fluid o_cropper_image" t-att-src="widget.imageData.originalSrc"/>
- </div>
- <div class="o_crop_options text-center mt16">
- <div class="btn-group btn-group-toggle" title="Aspect Ratio" data-toggle="buttons">
- <t t-foreach="widget.aspectRatioList" t-as="ratio">
- <t t-set="is_active" t-value="ratio[1] === widget.imageData.aspectRatio"/>
- <label t-attf-class="btn btn-secondary#{is_active and ' active' or ''}" data-event="ratio" t-att-data-label="ratio[1]" t-att-data-value="ratio[2]">
- <input type="radio" /><t t-esc="ratio[0]"/>
- </label>
- </t>
- </div>
- <div class="btn-group" role="group">
- <button type="button" class="btn btn-secondary" title="Zoom In" data-event="zoom" data-value="0.1"><i class="fa fa-search-plus"/></button>
- <button type="button" class="btn btn-secondary" title="Zoom Out" data-event="zoom" data-value="-0.1"><i class="fa fa-search-minus"/></button>
- </div>
- <div class="btn-group" role="group">
- <button type="button" class="btn btn-secondary" title="Rotate Left" data-event="rotate" data-value="-45"><i class="fa fa-rotate-left"/></button>
- <button type="button" class="btn btn-secondary" title="Rotate Right" data-event="rotate" data-value="45"><i class="fa fa-rotate-right"/></button>
- </div>
- <div class="btn-group" role="group">
- <button type="button" class="btn btn-secondary" title="Flip Horizontal" data-event="flip" data-value="horizontal" data-x="1"><i class="fa fa-arrows-h"/></button>
- <button type="button" class="btn btn-secondary" title="Flip Vertical" data-event="flip" data-value="vertical" data-y="1"><i class="fa fa-arrows-v"/></button>
- </div>
- <div class="btn-group" role="group">
- <button type="button" class="btn btn-secondary" title="Reset Image" data-event="reset"><i class="fa fa-refresh"/> Reset Image</button>
- </div>
- </div>
- </t>
- </div>
-
- <!--=================-->
<!-- Snippet options -->
<!--=================-->
diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml
index 7b85fb3998c..6e2ae656f2d 100644
--- a/addons/web_editor/static/src/xml/snippets.xml
+++ b/addons/web_editor/static/src/xml/snippets.xml
@@ -40,9 +40,9 @@
<t t-name="web_editor.many2one.button">
<div class="btn-group">
<a role="button" href="#" class="btn btn-secondary dropdown-toggle" data-toggle="dropdown" data-hover="dropdown" title="Search Contact" aria-label="Search Contact"><i class="fa fa-search"></i></a>
- <div class="dropdown-menu contact_menu" role="menu">
- <a role="menuitem" class="dropdown-item"><input href="#" type="email" placeholder="Search"/></a>
- </div>
+ <ul class="dropdown-menu contact_menu" role="menu">
+ <li><a role="menuitem" class="dropdown-item"><input href="#" type="email" placeholder="Search"/></a></li>
+ </ul>
</div>
</t>
diff --git a/addons/web_editor/static/src/xml/translator.xml b/addons/web_editor/static/src/xml/translator.xml
deleted file mode 100644
index dbd6c8287f6..00000000000
--- a/addons/web_editor/static/src/xml/translator.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<templates id="template" xml:space="preserve">
-<div t-name="web_editor.TranslatorInfoDialog">
- <p>You are about to enter the translation mode.</p>
- <p>Here are the visuals used to help you translate efficiently:</p>
- <ul class="oe_translate_examples">
- <li data-oe-translation-state="to_translate">Content to translate</li>
- <li data-oe-translation-state="translated">Translated content</li>
- </ul>
- <p>
- In this mode, you can only translate texts. To change the structure of the page, you must edit the master page.
- Each modification on the master page is automatically applied to all translated versions.
- </p>
-</div>
-</templates>
diff --git a/addons/web_editor/static/src/xml/wysiwyg.xml b/addons/web_editor/static/src/xml/wysiwyg.xml
new file mode 100644
index 00000000000..2330629a93b
--- /dev/null
+++ b/addons/web_editor/static/src/xml/wysiwyg.xml
@@ -0,0 +1,375 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+
+ <!--=================-->
+ <!-- Edition Dialogs -->
+ <!--=================-->
+
+ <!-- Alt Dialog (allows to change alt and title of page images) -->
+ <form t-name="wysiwyg.widgets.alt" action="#">
+ <div class="form-group row">
+ <label class="col-md-3 col-form-label" for="alt"
+ title="'Alt tag' specifies an alternate text for an image, if the image cannot be displayed (slow connection, missing image, screen reader ...).">
+ Description <small>(ALT Tag)</small>
+ </label>
+ <div class="col-md-8">
+ <input class="form-control" id="alt" required="required" t-att-value="widget.alt" type="text"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-md-3 col-form-label" for="title"
+ title="'Title tag' is shown as a tooltip when you hover the picture.">
+ Tooltip <small>(TITLE Tag)</small>
+ </label>
+ <div class="col-md-8">
+ <input class="form-control" id="title" required="required" t-att-value="widget.tag_title" type="text"/>
+ </div>
+ </div>
+ </form>
+
+ <!-- Media Dialog (allows to choose an img/pictogram/video) -->
+ <div t-name="wysiwyg.widgets.media">
+ <ul class="nav nav-tabs" role="tablist">
+ <li class="nav-item"><a role="tab" href="#editor-media-image" aria-controls="editor-media-image" data-toggle="tab" t-attf-class="nav-link#{widget.noImages ? ' d-none' : ''}">Image</a></li>
+ <li class="nav-item"><a role="tab" href="#editor-media-document" aria-controls="editor-media-document" data-toggle="tab" t-attf-class="nav-link#{widget.noDocuments ? ' d-none' : ''}">Document</a></li>
+ <li class="nav-item"><a role="tab" href="#editor-media-icon" aria-controls="editor-media-icon" data-toggle="tab" t-attf-class="nav-link#{widget.noIcons ? ' d-none' : ''}">Pictogram</a></li>
+ <li class="nav-item"><a role="tab" href="#editor-media-video" aria-controls="editor-media-video" data-toggle="tab" t-attf-class="nav-link#{widget.noVideos ? ' d-none' : ''}">Video</a></li>
+ </ul>
+ <!-- Tab panes -->
+ <div class="tab-content">
+ <div role="tabpanel" class="tab-pane fade" id="editor-media-image"/>
+ <div role="tabpanel" class="tab-pane fade" id="editor-media-document"/>
+ <div role="tabpanel" class="tab-pane fade" id="editor-media-icon"/>
+ <div role="tabpanel" class="tab-pane fade" id="editor-media-video"/>
+ </div>
+ </div>
+
+ <t t-name="wysiwyg.widgets.media.search">
+ <div class="input-group ml-auto">
+ <input type="text" class="form-control o_we_search" t-att-placeholder="searchPlaceholder.trim()"/>
+ <div class="input-group-append">
+ <div class="input-group-text o_we_search_icon">
+ <i class="fa fa-search" title="Search" role="img" aria-label="Search"/>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <!-- Image choosing part of the Media Dialog -->
+ <t t-name="wysiwyg.widgets.image">
+ <t t-set="iframeName" t-value="_.uniqueId('fileframe-')"/>
+ <form method="POST"
+ action="/web_editor/attachment/add"
+ enctype="multipart/form-data"
+ t-att-target="iframeName">
+ <input type="hidden" name="csrf_token" t-att-value="csrf_token"/>
+ <input type="hidden" name="filters" t-att-value="widget.firstFilters.join('_')"/>
+ <input t-if="widget.options.res_id" type="hidden" name="res_id" t-att-value="widget.options.res_id"/>
+ <input t-if="widget.options.res_model" type="hidden" name="res_model" t-att-value="widget.options.res_model"/>
+ <t t-call="wysiwyg.widgets.files.submenu">
+ <t t-set="isDocument" t-value="widget.options.document"/>
+ <t t-set="accept" t-value="widget.accept"/>
+ </t>
+ <div class="form-text"/>
+ <div class="existing-attachments"/>
+ <div class="mt-4 text-center mx-auto">
+ <button class="btn btn-primary o_load_more" type="button">Load more...</button>
+ <div class="mt-4 o_load_done_msg d-none">
+ <span><i>All attachments have been loaded</i></span>
+ </div>
+ </div>
+ <iframe class="o_file_upload_iframe d-none" t-att-name="iframeName"/>
+ </form>
+ </t>
+
+ <t t-name="wysiwyg.widgets.files.submenu">
+ <div class="form-inline align-items-center py-4">
+ <input type="file" class="d-none o_file_input" name="upload" t-att-accept="accept" multiple="multiple"/>
+ <input type="hidden" name="disable_optimization" value=""/>
+
+ <div class="btn-group">
+ <button type="button" class="btn btn-primary o_upload_media_button">
+ <t t-if="isDocument">Upload a document</t>
+ <t t-else="">Upload an image</t>
+ </button>
+ <t t-if="!isDocument">
+ <button class="btn btn-primary dropdown-toggle dropdown-toggle-split o_media_optimization" data-toggle="dropdown" type="button">
+ <span class="sr-only">Alternate Upload</span>
+ </button>
+ <div class="dropdown-menu" role="menu">
+ <button type="button" role="menuitem" class="dropdown-item o_upload_media_button_no_optimization">Upload image without optimization</button>
+ </div>
+ </t>
+ </div>
+
+ <div class="input-group align-items-center ml-2">
+ <input type="text" class="form-control o_we_url_input" name="url" t-attf-placeholder="https://www.odoo.com/#{isDocument ? 'mydocument' : 'logo.png'}"/>
+ <div class="input-group-append align-items-center">
+ <button type="button" class="btn btn-secondary o_upload_media_url_button" disabled="disabled">
+ <t t-if="isDocument">Add document</t>
+ <t t-else="">Add image</t>
+ </button>
+ <div class="ml-2">
+ <span class="o_we_url_success text-success d-none fa fa-lg fa-check" title="The URL seems valid"/>
+ <span class="o_we_url_warning text-warning d-none fa fa-lg fa-warning" title="The URL does not contain any image. The file will be added in the document section."/>
+ <span class="o_we_url_error text-danger d-none fa fa-lg fa-times" title="The URL does not seem to work"/>
+ </div>
+ </div>
+ </div>
+
+ <t t-call="wysiwyg.widgets.media.search">
+ <t t-set="searchPlaceholder">
+ <t t-if="isDocument">Search a document</t>
+ <t t-else="">Search an image</t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.existing">
+ <div role="dialog" class="modal" tabindex="-1">
+ <div class="modal-dialog select-image">
+ <div class="modal-content">
+ <header class="modal-header">
+ <h3 class="modal-title">Select a Picture</h3>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">×</button>
+ </header>
+ <main class="modal-body o_has_error">
+ <div class="existing-attachments"/>
+ <div class="form-text"/>
+ </main>
+ <footer class="modal-footer">
+ <button data-dismiss="modal" type="button" aria-label="Discard">Discard</button>
+ </footer>
+ </div>
+ </div>
+ </div>
+ </t>
+ <t t-name="wysiwyg.widgets.files.existing.content">
+ <div class="existing-attachments">
+ <div class="row mt16 ml-auto" t-as="row" t-foreach="rows">
+ <div class="col-2 o_existing_attachment_cell" t-as="attachment" t-foreach="row">
+ <i t-if="attachment.res_model === 'ir.ui.view'" class="fa fa-trash o_existing_attachment_remove" title="This file is a public view attachment" role="img" aria-label="This file is a public view attachment" t-att-data-id="attachment.id"/>
+ <i t-else="" class="fa fa-trash o_existing_attachment_remove" title="This file is attached to the current record" role="img" aria-label="This file is attached to the current record" t-att-data-id="attachment.id"/>
+ <div class="o_attachment_border" t-att-style="attachment.res_model === 'ir.ui.view' ? null : 'border: 1px solid #5cb85c;'"><div t-att-data-src="attachment.src" t-att-data-url="attachment.url" role="img" t-att-aria-label="attachment.name" t-att-title="attachment.name" t-att-data-id="attachment.id" t-att-data-mimetype="attachment.mimetype" t-attf-class="o_image {{withEffect ? 'o_image_loading' : ''}}"/></div>
+ <t t-if="isDocument">
+ <small class="o_file_name"><t t-esc="attachment.name"/></small>
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+ <t t-name="wysiwyg.widgets.image.existing.error">
+ <div class="form-text">
+ <p>The image could not be deleted because it is used in the
+ following pages or views:</p>
+ <ul t-as="view" t-foreach="views">
+ <li>
+ <a t-attf-href="/web#model=ir.ui.view&amp;id=#{view.id}">
+ <t t-esc="view.name"/>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </t>
+
+ <!-- Icon choosing part of the Media Dialog -->
+ <t t-name="wysiwyg.widgets.font-icons">
+ <form action="#">
+ <div class="form-inline align-items-center py-4">
+ <t t-call="wysiwyg.widgets.media.search">
+ <t t-set="searchPlaceholder">Search a pictogram</t>
+ </t>
+ </div>
+ <div class="font-icons-icons">
+ <t t-call="wysiwyg.widgets.font-icons.icons">
+ <t t-set="iconsParser" t-value="widget.iconsParser"/>
+ </t>
+ </div>
+ </form>
+ </t>
+ <t t-name="wysiwyg.widgets.font-icons.icons">
+ <t t-as="data" t-foreach="iconsParser">
+ <span t-foreach="data.cssData" t-as="cssData"
+ t-att-data-id="cssData.names[0]"
+ t-att-title="cssData.names[0]"
+ t-att-aria-label="cssData.names[0]" role="img"
+ t-attf-class="font-icons-icon #{data.base} #{cssData.names[0]}"
+ t-att-data-alias="cssData.names.join(',')"/>
+ </t>
+ </t>
+
+ <!-- Video choosing part of the Media Dialog -->
+ <t t-name="wysiwyg.widgets.video">
+ <form action="#" class="row">
+ <div class="col mt-4 o_video_dialog_form">
+ <div class="form-group mb-2" id="o_video_form_group">
+ <label class="col-form-label" for="o_video_text">
+ Video code <small>(URL or Embed)</small>
+ <div class="o_validate_feedback">
+ <i class="fa fa-check text-success" role="img" aria-label="Checked" title="Checked"/>
+ <i class="fa fa-exclamation-triangle text-danger" role="img" aria-label="Attention" title="Attention"/>
+ </div>
+ </label>
+ <textarea class="form-control" id="o_video_text" placeholder="Copy-paste your URL or embed code here"/>
+ </div>
+ <div class="text-right">
+ <small class="text-muted">Accepts <b><i>Youtube</i></b>, <b><i>Instagram</i></b>, <b><i>Vine.co</i></b>, <b><i>Vimeo</i></b>, <b><i>Dailymotion</i></b> and <b><i>Youku</i></b> videos</small>
+ </div>
+ <div class="o_video_dialog_options d-none mt-4">
+ <div class="o_yt_option o_vim_option o_dm_option">
+ <label class="o_switch mb0"><input id="o_video_autoplay" type="checkbox"/><span/>Autoplay</label>
+ </div>
+ <div class="o_yt_option o_vim_option">
+ <label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label>
+ </div>
+ <div class="o_yt_option o_dm_option">
+ <label class="o_switch mb0"><input id="o_video_hide_controls" type="checkbox"/><span/>Hide player controls</label>
+ </div>
+ <div class="o_yt_option">
+ <label class="o_switch mb0"><input id="o_video_hide_fullscreen" type="checkbox"/><span/>Hide fullscreen button</label>
+ </div>
+ <div class="o_yt_option">
+ <label class="o_switch mb0"><input id="o_video_hide_yt_logo" type="checkbox"/><span/>Hide Youtube logo</label>
+ </div>
+ <div class="o_dm_option">
+ <label class="o_switch mb0"><input id="o_video_hide_dm_logo" type="checkbox"/><span/>Hide Dailymotion logo</label>
+ </div>
+ <div class="o_dm_option">
+ <label class="o_switch mb0"><input id="o_video_hide_dm_share" type="checkbox"/><span/>Hide sharing button</label>
+ </div>
+ </div>
+ </div>
+ <div class="col-md-6">
+ <div id="video-preview" class="p-3">
+ <div class="o_video_dialog_preview_text small mb-2 d-none">Preview</div>
+ <div class="media_iframe_video">
+ <div class="media_iframe_video_size"/>
+ <iframe class="o_video_dialog_iframe" allowfullscreen="allowfullscreen" frameborder="0" src=""/>
+ </div>
+ </div>
+ </div>
+ </form>
+ </t>
+
+
+ <!-- Link Dialog (allows to choose a style and content for a link on the page) -->
+ <div t-name="wysiwyg.widgets.link" class="o_link_dialog">
+ <div class="row">
+ <form class="col-lg-8">
+ <div t-attf-class="form-group row#{widget.needLabel ? '' : ' d-none'}">
+ <label class="col-form-label col-md-3" for="o_link_dialog_label_input">Link Label</label>
+ <div class="col-md-9">
+ <input type="text" name="label" class="form-control" id="o_link_dialog_label_input" required="required" t-att-value="widget.data.text"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-form-label col-md-3" for="o_link_dialog_url_input">URL or Email</label>
+ <div class="col-md-9">
+ <input type="text" name="url" class="form-control" id="o_link_dialog_url_input" required="required"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-form-label col-md-3">Size</label>
+ <div class="col-md-9">
+ <select name="link_style_size" class="form-control link-style">
+ <option value="sm">Small</option>
+ <option value="" selected="selected">Medium</option>
+ <option value="lg">Large</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-form-label col-md-3">Style</label>
+ <div class="col-md-9">
+ <select name="link_style_shape" class="form-control link-shape">
+ <option value="" selected="selected">Default</option>
+ <option value="outline">Outline</option>
+ <option value="rounded-circle">Rounded</option>
+ <option value="outline,rounded-circle">Outline-Rounded</option>
+ <option value="flat">Flat</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-form-label col-md-3">Color</label>
+ <div class="col-md-9">
+ <div class="o_link_dialog_color">
+ <label role="button" t-attf-class="o_link_dialog_color_item o_btn_preview btn btn-link text-center" data-color="">
+ <span>L</span>
+ <input class="d-none link-style" name="link_style_color" type="radio" value=""/>
+ <i class="fa"/>
+ </label>
+ <t t-foreach="['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'primary', 'secondary', 'success', 'info', 'warning', 'danger']" t-as="color">
+ <label role="button" t-attf-class="o_link_dialog_color_item o_btn_preview btn btn-#{color}" t-attf-aria-label="Color for #{color}" t-attf-title="Color for #{color}">
+ <input type="radio" name="link_style_color" class="d-none link-style" t-att-value="color"/>
+ <i class="fa"/>
+ </label>
+ </t>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="offset-md-3 col-md-9">
+ <label class="o_switch">
+ <input type="checkbox" name="is_new_window" t-att-checked="widget.data.isNewWindow ? 'checked' : undefined"/>
+ <span/>
+ Open in new window
+ </label>
+ </div>
+ </div>
+ </form>
+ <div class="col-lg-4 o_link_dialog_preview">
+ <div class="form-group text-center">
+ <label>Preview</label>
+ <div style="overflow-x: auto; max-width: 100%; max-height: 200px;">
+ <a href="#" id="link-preview" aria-label="Preview" title="Preview"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Crop Image Dialog (allows to crop image on the page) -->
+ <div t-name="wysiwyg.widgets.crop_image" class="o_crop_image_dialog">
+ <div t-if="widget.imageData.isExternalImage" class="alert alert-warning text-center" role="alert">
+ <i class="fa fa-exclamation-triangle fa-2x"/>
+ <h4>This image is an external image</h4>
+ <p>
+ This type of image is not supported for cropping.<br/>
+ If you want to crop it, please first download it from the original source and upload it in Odoo.
+ </p>
+ </div>
+ <t t-else="">
+ <div class="o_crop_area">
+ <img class="img img-fluid o_cropper_image" t-att-src="widget.imageData.originalSrc"/>
+ </div>
+ <div class="o_crop_options text-center mt16">
+ <div class="btn-group btn-group-toggle" title="Aspect Ratio" data-toggle="buttons">
+ <t t-foreach="widget.aspectRatioList" t-as="ratio">
+ <t t-set="is_active" t-value="ratio[1] === widget.imageData.aspectRatio"/>
+ <label t-attf-class="btn btn-secondary#{is_active and ' active' or ''}" data-event="ratio" t-att-data-label="ratio[1]" t-att-data-value="ratio[2]">
+ <input type="radio" /><t t-esc="ratio[0]"/>
+ </label>
+ </t>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" class="btn btn-secondary" title="Zoom In" data-event="zoom" data-value="0.1"><i class="fa fa-search-plus"/></button>
+ <button type="button" class="btn btn-secondary" title="Zoom Out" data-event="zoom" data-value="-0.1"><i class="fa fa-search-minus"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" class="btn btn-secondary" title="Rotate Left" data-event="rotate" data-value="-45"><i class="fa fa-rotate-left"/></button>
+ <button type="button" class="btn btn-secondary" title="Rotate Right" data-event="rotate" data-value="45"><i class="fa fa-rotate-right"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" class="btn btn-secondary" title="Flip Horizontal" data-event="flip" data-value="horizontal" data-x="1"><i class="fa fa-arrows-h"/></button>
+ <button type="button" class="btn btn-secondary" title="Flip Vertical" data-event="flip" data-value="vertical" data-y="1"><i class="fa fa-arrows-v"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" class="btn btn-secondary" title="Reset Image" data-event="reset"><i class="fa fa-refresh"/> Reset Image</button>
+ </div>
+ </div>
+ </t>
+ </div>
+
+</templates>
diff --git a/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml b/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml
new file mode 100644
index 00000000000..882a0edea11
--- /dev/null
+++ b/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates>
+ <div t-name="wysiwyg.plugin.font.paletteButton" t-attf-class="note-btn-group btn-group {{className}}">
+ <button type="button" class="note-btn btn btn-light btn-sm dropdown-toggle" tabindex="-1" data-toggle="dropdown"><i
+ t-att-class="icon"></i></button>
+ <div class="dropdown-menu" />
+ </div>
+
+ <div t-name="wysiwyg.plugin.font.colorPalette" class="note-palette">
+ <button type="button" class="note-color-reset btn btn-light note-color-btn bg-undefined">
+ <t t-esc="lang.color.resetToDefault" /></button>
+ <div class="note-holder">
+ <div class="note-color-palette">
+ <h6 class="mt-2">Theme colors</h6>
+ <div class="o_theme_color_placeholder" />
+ <h6 class="mt-2">Transparent colors</h6>
+ <div class="o_transparent_color_placeholder" />
+ <h6 class="mt-2">Common colors</h6>
+ <div class="o_common_color_placeholder">
+ <div class="note-color-row" t-foreach="colors" t-as="rowColors">
+ <button t-foreach="rowColors" t-as="color" type="button" class="note-color-btn" data-toggle="button"
+ tabindex="-1" t-attf-style="background-color:{{color}}" t-att-data-value="color"
+ t-att-title="color" />
+ </div>
+ </div>
+ </div>
+ <h6 class="note-custom-color mt8">
+ <t t-esc="lang.color.customColor" />
+ </h6>
+ <button class="note-custom-color-btn note-color-btn" style="display: none;"></button>
+ </div>
+ </div>
+
+ <div t-name="wysiwyg.widgets.ColorpickerDialog" class="o_colorpicker_widget">
+ <div class="row">
+ <div class="col-auto">
+ <div class="o_color_pick_area d-inline-block border">
+ <div class="o_picker_pointer rounded-circle" />
+ </div>
+ <div class="o_color_slider d-inline-block ml-2 border">
+ <div class="o_slider_pointer" />
+ </div>
+ <div class="o_opacity_slider d-inline-block ml-2 border">
+ <div class="o_opacity_pointer" />
+ </div>
+ </div>
+ <form class="col o_color_picker_inputs">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <span class="input-group-text rounded-0 o_color_preview" />
+ </div>
+ <input type="text" class="form-control o_hex_input" data-color-method="hex" />
+ </div>
+ <div class="form-row mt-2">
+ <div class="col">
+ <div class="form-group">
+ <label for="red">Red</label>
+ <input type="text" class="form-control o_red_input" data-color-method="rgb" id="red" />
+ </div>
+ <div class="form-group">
+ <label for="green">Green</label>
+ <input type="text" class="form-control o_green_input" data-color-method="rgb" id="green" />
+ </div>
+ <div class="form-group">
+ <label for="blue">Blue</label>
+ <input type="text" class="form-control o_blue_input" data-color-method="rgb" id="blue" />
+ </div>
+ </div>
+ <div class="col">
+ <div class="form-group">
+ <label for="hue">Hue</label>
+ <input type="text" class="form-control o_hue_input" data-color-method="hsl" id="hue" />
+ </div>
+ <div class="form-group">
+ <label for="saturation">Saturation %</label>
+ <input type="text" class="form-control o_saturation_input" data-color-method="hsl" id="saturation" />
+ </div>
+ <div class="form-group">
+ <label for="lightness">Lightness %</label>
+ <input type="text" class="form-control o_lightness_input" data-color-method="hsl" id="lightness" />
+ </div>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="opacity">Opacity %</label>
+ <input type="text" class="form-control o_opacity_input" data-color-method="opacity" id="opacity" />
+ </div>
+ </form>
+ </div>
+ </div>
+</templates>