summaryrefslogtreecommitdiff
path: root/addons/web_editor/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web_editor/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web_editor/static/src')
-rw-r--r--addons/web_editor/static/src/img/curved_arrow.svg14
-rw-r--r--addons/web_editor/static/src/img/snippet_disabled.svg7
-rw-r--r--addons/web_editor/static/src/img/snippets_options/bg_shape.svg11
-rw-r--r--addons/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg12
-rw-r--r--addons/web_editor/static/src/js/backend/convert_inline.js485
-rw-r--r--addons/web_editor/static/src/js/backend/field_html.js536
-rw-r--r--addons/web_editor/static/src/js/base.js173
-rw-r--r--addons/web_editor/static/src/js/common/ace.js944
-rw-r--r--addons/web_editor/static/src/js/common/utils.js266
-rw-r--r--addons/web_editor/static/src/js/editor/custom_colors.js0
-rw-r--r--addons/web_editor/static/src/js/editor/editor.js289
-rw-r--r--addons/web_editor/static/src/js/editor/image_processing.js335
-rw-r--r--addons/web_editor/static/src/js/editor/rte.js816
-rw-r--r--addons/web_editor/static/src/js/editor/rte.summernote.js1280
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.editor.js2776
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.options.js4908
-rw-r--r--addons/web_editor/static/src/js/editor/summernote.js2527
-rw-r--r--addons/web_editor/static/src/js/frontend/loader.js28
-rw-r--r--addons/web_editor/static/src/js/frontend/loader_loading.js33
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/fonts.js99
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/root.js91
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js62
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js410
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js81
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js213
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js339
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media.js1463
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js279
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js29
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg.js274
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js132
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js56
-rw-r--r--addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js0
-rw-r--r--addons/web_editor/static/src/scss/13_0_color_system_support_primary_variables.scss1
-rw-r--r--addons/web_editor/static/src/scss/bootstrap_overridden.scss76
-rw-r--r--addons/web_editor/static/src/scss/bootstrap_overridden_backend.scss14
-rw-r--r--addons/web_editor/static/src/scss/secondary_variables.scss137
-rw-r--r--addons/web_editor/static/src/scss/web_editor.backend.scss69
-rw-r--r--addons/web_editor/static/src/scss/web_editor.common.scss782
-rw-r--r--addons/web_editor/static/src/scss/web_editor.frontend.scss74
-rw-r--r--addons/web_editor/static/src/scss/web_editor.variables.scss728
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg.scss522
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg_iframe.scss27
-rw-r--r--addons/web_editor/static/src/scss/wysiwyg_snippets.scss1951
-rw-r--r--addons/web_editor/static/src/xml/ace.xml63
-rw-r--r--addons/web_editor/static/src/xml/backend.xml20
-rw-r--r--addons/web_editor/static/src/xml/editor.xml42
-rw-r--r--addons/web_editor/static/src/xml/snippets.xml102
-rw-r--r--addons/web_editor/static/src/xml/wysiwyg.xml579
-rw-r--r--addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml33
50 files changed, 24188 insertions, 0 deletions
diff --git a/addons/web_editor/static/src/img/curved_arrow.svg b/addons/web_editor/static/src/img/curved_arrow.svg
new file mode 100644
index 00000000..a8ed9acd
--- /dev/null
+++ b/addons/web_editor/static/src/img/curved_arrow.svg
@@ -0,0 +1,14 @@
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1144 1280">
+<g transform="translate(0,1280) scale(0.1,-0.1)" fill="#000000" stroke="none">
+<path d="M7166 11004 c-603 -901 -1094 -1639 -1092 -1641 1 -2 369 42 817 97
+448 55 825 101 838 103 30 3 26 14 86 -278 131 -634 218 -1317 255 -2010 13
+-246 13 -898 0 -1135 -71 -1262 -367 -2292 -883 -3065 -162 -242 -308 -418
+-541 -650 -661 -658 -1465 -1094 -2581 -1400 -1069 -293 -2283 -471 -3660
+-536 -132 -6 -242 -13 -244 -15 -6 -5 16 -190 30 -255 9 -43 13 -47 47 -53
+108 -17 1148 -2 1627 24 2760 152 4778 866 6094 2155 506 496 887 1018 1216
+1670 444 877 715 1860 819 2970 35 365 41 510 41 1000 0 581 -17 880 -76 1355
+-19 158 -65 452 -74 477 -5 14 -54 7 730 104 363 44 662 83 665 85 2 3 0 8 -6
+12 -6 4 -682 594 -1503 1312 -822 718 -1497 1306 -1501 1308 -4 2 -501 -734
+-1104 -1634z"/>
+</g>
+</svg>
diff --git a/addons/web_editor/static/src/img/snippet_disabled.svg b/addons/web_editor/static/src/img/snippet_disabled.svg
new file mode 100644
index 00000000..1d506689
--- /dev/null
+++ b/addons/web_editor/static/src/img/snippet_disabled.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
+ <g fill="none" class="snippet_disabled">
+ <path fill="#E16F2B" d="M7.5 0c1.36 0 2.616.335 3.765 1.006a7.466 7.466 0 0 1 2.73 2.73A7.337 7.337 0 0 1 15 7.5c0 1.36-.335 2.616-1.006 3.765a7.466 7.466 0 0 1-2.73 2.73A7.337 7.337 0 0 1 7.5 15a7.337 7.337 0 0 1-3.765-1.006 7.466 7.466 0 0 1-2.73-2.73A7.337 7.337 0 0 1 0 7.5c0-1.36.335-2.616 1.006-3.765a7.466 7.466 0 0 1 2.73-2.73A7.337 7.337 0 0 1 7.5 0z" class="path"/>
+ <path fill="#E17D41" d="M7.5 1c1.18 0 2.267.29 3.263.872a6.47 6.47 0 0 1 2.365 2.365C13.71 5.233 14 6.321 14 7.5a6.35 6.35 0 0 1-.872 3.263 6.47 6.47 0 0 1-2.365 2.365A6.358 6.358 0 0 1 7.5 14a6.35 6.35 0 0 1-3.263-.872 6.47 6.47 0 0 1-2.365-2.365A6.358 6.358 0 0 1 1 7.5c0-1.18.29-2.267.872-3.263a6.47 6.47 0 0 1 2.365-2.365A6.358 6.358 0 0 1 7.5 1z" class="path"/>
+ <path fill="#FFF" d="M8.51 10c.09 0 .167.03.23.093a.31.31 0 0 1 .093.23v1.855a.31.31 0 0 1-.093.23.313.313 0 0 1-.23.092h-2a.34.34 0 0 1-.24-.098.3.3 0 0 1-.103-.224v-1.856a.3.3 0 0 1 .104-.224.34.34 0 0 1 .24-.098zm.136-7.5c.097 0 .18.026.25.078A.19.19 0 0 1 9 2.754l-.188 6.064c-.006.065-.043.122-.109.171a.4.4 0 0 1-.245.073H6.531a.423.423 0 0 1-.25-.073c-.07-.049-.104-.106-.104-.17L6 2.753a.19.19 0 0 1 .104-.176.405.405 0 0 1 .25-.078z" class="shape"/>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/src/img/snippets_options/bg_shape.svg b/addons/web_editor/static/src/img/snippets_options/bg_shape.svg
new file mode 100644
index 00000000..838ddc53
--- /dev/null
+++ b/addons/web_editor/static/src/img/snippets_options/bg_shape.svg
@@ -0,0 +1,11 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="14" height="11" viewBox="0 0 14 11">
+ <g fill="none" fill-rule="evenodd" class="symbols">
+ <g fill="#D9D9D9" class="shape" transform="translate(-176 -6)">
+ <g class="group" transform="translate(167)">
+ <g class="bg_shape" transform="translate(9 6)">
+ <path d="M12 0a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h10zm0 2l-1.59 1.68a7 7 0 0 1-4.207 2.134l-.155.02A4.967 4.967 0 0 0 2.224 8.55L2 9h10V2z" class="o_graphic"/>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg b/addons/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg
new file mode 100644
index 00000000..7d701fa3
--- /dev/null
+++ b/addons/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+ <g fill="none" fill-rule="evenodd" class="o_overlay_move_drag">
+ <g fill="#FFF" class="group" transform="translate(3.5 6)">
+ <polygon points="0 0 0 3 3 3 3 0" class="o_graphic"/>
+ <polygon points="5 0 5 3 8 3 8 0" class="o_graphic"/>
+ <polygon points="10 0 10 3 13 3 13 0" class="o_graphic"/>
+ <polygon points="0 5 0 8 3 8 3 5" class="o_graphic"/>
+ <polygon points="5 5 5 8 8 8 8 5" class="o_graphic"/>
+ <polygon points="10 5 10 8 13 8 13 5" class="o_graphic"/>
+ </g>
+ </g>
+</svg>
diff --git a/addons/web_editor/static/src/js/backend/convert_inline.js b/addons/web_editor/static/src/js/backend/convert_inline.js
new file mode 100644
index 00000000..071caa3f
--- /dev/null
+++ b/addons/web_editor/static/src/js/backend/convert_inline.js
@@ -0,0 +1,485 @@
+odoo.define('web_editor.convertInline', function (require) {
+'use strict';
+
+var FieldHtml = require('web_editor.field.html');
+
+/**
+ * Returns the css rules which applies on an element, tweaked so that they are
+ * browser/mail client ok.
+ *
+ * @param {DOMElement} a
+ * @returns {Object} css property name -> css property value
+ */
+function getMatchedCSSRules(a) {
+ var i, r, k;
+ var doc = a.ownerDocument;
+ var rulesCache = a.ownerDocument._rulesCache || (a.ownerDocument._rulesCache = []);
+
+ if (!rulesCache.length) {
+ 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
+ try {
+ rules = sheets[i].rules || sheets[i].cssRules;
+ } catch (e) {
+ console.warn("Can't read the css rules of: " + sheets[i].href, e);
+ continue;
+ }
+ if (rules) {
+ for (r = rules.length-1; r >= 0; r--) {
+ var selectorText = rules[r].selectorText;
+ if (selectorText &&
+ rules[r].cssText &&
+ selectorText !== '*' &&
+ selectorText.indexOf(':hover') === -1 &&
+ selectorText.indexOf(':before') === -1 &&
+ selectorText.indexOf(':after') === -1 &&
+ selectorText.indexOf(':active') === -1 &&
+ selectorText.indexOf(':link') === -1 &&
+ selectorText.indexOf('::') === -1 &&
+ selectorText.indexOf("'") === -1) {
+ var st = selectorText.split(/\s*,\s*/);
+ for (k = 0 ; k < st.length ; k++) {
+ rulesCache.push({ 'selector': st[k], 'style': rules[r].style });
+ }
+ }
+ }
+ }
+ }
+ rulesCache.reverse();
+ }
+
+ var css = [];
+ var style;
+ a.matches = a.matches || a.webkitMatchesSelector || a.mozMatchesSelector || a.msMatchesSelector || a.oMatchesSelector;
+ for (r = 0; r < rulesCache.length; r++) {
+ if (a.matches(rulesCache[r].selector)) {
+ style = rulesCache[r].style;
+ if (style.parentRule) {
+ var style_obj = {};
+ var len;
+ for (k = 0, len = style.length ; k < len ; k++) {
+ if (style[k].indexOf('animation') !== -1) {
+ continue;
+ }
+ style_obj[style[k]] = style[style[k].replace(/-(.)/g, function (a, b) { return b.toUpperCase(); })];
+ if (new RegExp(style[k] + '\s*:[^:;]+!important' ).test(style.cssText)) {
+ style_obj[style[k]] += ' !important';
+ }
+ }
+ rulesCache[r].style = style = style_obj;
+ }
+ css.push([rulesCache[r].selector, style]);
+ }
+ }
+
+ function specificity(selector) {
+ // http://www.w3.org/TR/css3-selectors/#specificity
+ var a = 0;
+ selector = selector.replace(/#[a-z0-9_-]+/gi, function () { a++; return ''; });
+ var b = 0;
+ selector = selector.replace(/(\.[a-z0-9_-]+)|(\[.*?\])/gi, function () { b++; return ''; });
+ var c = 0;
+ selector = selector.replace(/(^|\s+|:+)[a-z0-9_-]+/gi, function (a) { if (a.indexOf(':not(')===-1) c++; return ''; });
+ return a*100 + b*10 + c;
+ }
+ css.sort(function (a, b) { return specificity(a[0]) - specificity(b[0]); });
+
+ style = {};
+ _.each(css, function (v,k) {
+ _.each(v[1], function (v,k) {
+ if (v && _.isString(v) && k.indexOf('-webkit') === -1 && (!style[k] || style[k].indexOf('important') === -1 || v.indexOf('important') !== -1)) {
+ style[k] = v;
+ }
+ });
+ });
+
+ _.each(style, function (v,k) {
+ if (v.indexOf('important') !== -1) {
+ style[k] = v.slice(0, v.length-11);
+ }
+ });
+
+ if (style.display === 'block') {
+ delete style.display;
+ }
+
+ // The css generates all the attributes separately and not in simplified form.
+ // In order to have a better compatibility (outlook for example) we simplify the css tags.
+ // e.g. border-left-style: none; border-bottom-s .... will be simplified in border-style = none
+ _.each([
+ {property: 'margin'},
+ {property: 'padding'},
+ {property: 'border', propertyEnd: '-style', defaultValue: 'none'},
+ ], function (propertyInfo) {
+ var p = propertyInfo.property;
+ var e = propertyInfo.propertyEnd || '';
+ var defVal = propertyInfo.defaultValue || 0;
+
+ if (style[p+'-top'+e] || style[p+'-right'+e] || style[p+'-bottom'+e] || style[p+'-left'+e]) {
+ if (style[p+'-top'+e] === style[p+'-right'+e] && style[p+'-top'+e] === style[p+'-bottom'+e] && style[p+'-top'+e] === style[p+'-left'+e]) {
+ // keep => property: [top/right/bottom/left value];
+ style[p+e] = style[p+'-top'+e];
+ }
+ else {
+ // keep => property: [top value] [right value] [bottom value] [left value];
+ style[p+e] = (style[p+'-top'+e] || defVal) + ' ' + (style[p+'-right'+e] || defVal) + ' ' + (style[p+'-bottom'+e] || defVal) + ' ' + (style[p+'-left'+e] || defVal);
+ if (style[p+e].indexOf('inherit') !== -1 || style[p+e].indexOf('initial') !== -1) {
+ // keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value];
+ delete style[p+e];
+ return;
+ }
+ }
+ delete style[p+'-top'+e];
+ delete style[p+'-right'+e];
+ delete style[p+'-bottom'+e];
+ delete style[p+'-left'+e];
+ }
+ });
+
+ if (style['border-bottom-left-radius']) {
+ style['border-radius'] = style['border-bottom-left-radius'];
+ delete style['border-bottom-left-radius'];
+ delete style['border-bottom-right-radius'];
+ delete style['border-top-left-radius'];
+ delete style['border-top-right-radius'];
+ }
+
+ // if the border styling is initial we remove it to simplify the css tags for compatibility.
+ // Also, since we do not send a css style tag, the initial value of the border is useless.
+ _.each(_.keys(style), function (k) {
+ if (k.indexOf('border') !== -1 && style[k] === 'initial') {
+ delete style[k];
+ }
+ });
+
+ // text-decoration rule is decomposed in -line, -color and -style. This is
+ // however not supported by many browser/mail clients and the editor does
+ // not allow to change -color and -style rule anyway
+ if (style['text-decoration-line']) {
+ style['text-decoration'] = style['text-decoration-line'];
+ delete style['text-decoration-line'];
+ delete style['text-decoration-color'];
+ delete style['text-decoration-style'];
+ delete style['text-decoration-thickness'];
+ }
+
+ // text-align inheritance does not seem to get past <td> elements on some
+ // mail clients
+ if (style['text-align'] === 'inherit') {
+ var $el = $(a).parent();
+ do {
+ var align = $el.css('text-align');
+ if (_.indexOf(['left', 'right', 'center', 'justify'], align) >= 0) {
+ style['text-align'] = align;
+ break;
+ }
+ $el = $el.parent();
+ } while ($el.length && !$el.is('html'));
+ }
+
+ return style;
+}
+
+/**
+ * Converts font icons to images.
+ *
+ * @param {jQuery} $editable - the element in which the font icons have to be
+ * converted to images
+ */
+function fontToImg($editable) {
+ var fonts = odoo.__DEBUG__.services["wysiwyg.fonts"];
+
+ $editable.find('.fa').each(function () {
+ var $font = $(this);
+ var icon, content;
+ _.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];
+ return true;
+ }
+ });
+ });
+ if (content) {
+ var color = $font.css('color').replace(/\s/g, '');
+ $font.replaceWith($('<img/>', {
+ src: _.str.sprintf('/web_editor/font_to_img/%s/%s/%s', content.charCodeAt(0), window.encodeURI(color), Math.max(1, Math.round($font.height()))),
+ 'data-class': $font.attr('class'),
+ 'data-style': $font.attr('style'),
+ class: $font.attr('class').replace(new RegExp('(^|\\s+)' + icon + '(-[^\\s]+)?', 'gi'), ''), // remove inline font-awsome style
+ style: $font.attr('style'),
+ }).css({height: 'auto', width: 'auto'}));
+ } else {
+ $font.remove();
+ }
+ });
+}
+
+/**
+ * Converts images which were the result of a font icon convertion to a font
+ * icon again.
+ *
+ * @param {jQuery} $editable - the element in which the images will be converted
+ * back to font icons
+ */
+function imgToFont($editable) {
+ $editable.find('img[src*="/web_editor/font_to_img/"]').each(function () {
+ var $img = $(this);
+ $img.replaceWith($('<span/>', {
+ class: $img.data('class'),
+ style: $img.data('style')
+ }));
+ });
+}
+
+/*
+ * Utility function to apply function over descendants elements
+ *
+ * This is needed until the following issue of jQuery is solved:
+ * https://github.com./jquery/sizzle/issues/403
+ *
+ * @param {Element} node The root Element node
+ * @param {Function} func The function applied over descendants
+ */
+function applyOverDescendants(node, func) {
+ node = node.firstChild;
+ while (node) {
+ if (node.nodeType === 1) {
+ func(node);
+ applyOverDescendants(node, func);
+ }
+ var $node = $(node);
+ if (node.nodeName === 'A' && $node.hasClass('btn') && !$node.children().length && $(node).parents('.o_outlook_hack').length) {
+ node = $(node).parents('.o_outlook_hack')[0];
+ }
+ else if (node.nodeName === 'IMG' && $node.parent('p').hasClass('o_outlook_hack')) {
+ node = $node.parent()[0];
+ }
+ node = node.nextSibling;
+ }
+}
+
+/**
+ * Converts css style to inline style (leave the classes on elements but forces
+ * the style they give as inline style).
+ *
+ * @param {jQuery} $editable
+ */
+function classToStyle($editable) {
+ applyOverDescendants($editable[0], function (node) {
+ var $target = $(node);
+ var css = getMatchedCSSRules(node);
+ var style = $target.attr('style') || '';
+ _.each(css, function (v,k) {
+ if (!(new RegExp('(^|;)\s*' + k).test(style))) {
+ style = k+':'+v+';'+style;
+ }
+ });
+ if (_.isEmpty(style)) {
+ $target.removeAttr('style');
+ } else {
+ $target.attr('style', style);
+ }
+ // Apple Mail
+ if (node.nodeName === 'TD' && !node.childNodes.length) {
+ node.innerHTML = '&nbsp;';
+ }
+
+ // Outlook
+ if (node.nodeName === 'A' && $target.hasClass('btn') && !$target.hasClass('btn-link') && !$target.children().length) {
+ var $hack = $('<table class="o_outlook_hack" style="display: inline-table;vertical-align:middle"><tr><td></td></tr></table>');
+ $hack.find('td')
+ .attr('height', $target.outerHeight())
+ .css({
+ 'text-align': $target.parent().css('text-align'),
+ 'margin': $target.css('padding'),
+ 'border-radius': $target.css('border-radius'),
+ 'background-color': $target.css('background-color'),
+ });
+ $target.after($hack);
+ $target.appendTo($hack.find('td'));
+ // the space add a line when it's a table but it's invisible when it's a link
+ node = $hack[0].previousSibling;
+ if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) {
+ $(node).remove();
+ }
+ node = $hack[0].nextSibling;
+ if (node && node.nodeType === Node.TEXT_NODE && !node.textContent.match(/\S/)) {
+ $(node).remove();
+ }
+ }
+ else if (node.nodeName === 'IMG' && $target.is('.mx-auto.d-block')) {
+ $target.wrap('<p class="o_outlook_hack" style="text-align:center;margin:0"/>');
+ }
+ });
+}
+
+/**
+ * Removes the inline style which is not necessary (because, for example, a
+ * class on an element will induce the same style).
+ *
+ * @param {jQuery} $editable
+ */
+function styleToClass($editable) {
+ // Outlook revert
+ $editable.find('.o_outlook_hack').each(function () {
+ $(this).after($('a,img', this));
+ }).remove();
+
+ var $c = $('<span/>').appendTo($editable[0].ownerDocument.body);
+
+ applyOverDescendants($editable[0], function (node) {
+ var $target = $(node);
+ var css = getMatchedCSSRules(node);
+ var style = '';
+ _.each(css, function (v,k) {
+ if (!(new RegExp('(^|;)\s*' + k).test(style))) {
+ style = k+':'+v+';'+style;
+ }
+ });
+ css = ($c.attr('style', style).attr('style') || '').split(/\s*;\s*/);
+ style = ($target.attr('style') || '').replace(/\s*:\s*/, ':').replace(/\s*;\s*/, ';');
+ _.each(css, function (v) {
+ style = style.replace(v, '');
+ });
+ style = style.replace(/;+(\s;)*/g, ';').replace(/^;/g, '');
+ if (style !== '') {
+ $target.attr('style', style);
+ } else {
+ $target.removeAttr('style');
+ }
+ });
+ $c.remove();
+}
+
+/**
+ * Converts css display for attachment link to real image.
+ * Without this post process, the display depends on the css and the picture
+ * does not appear when we use the html without css (to send by email for e.g.)
+ *
+ * @param {jQuery} $editable
+ */
+function attachmentThumbnailToLinkImg($editable) {
+ $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.prepend($img);
+ });
+}
+
+/**
+ * Revert attachmentThumbnailToLinkImg changes
+ *
+ * @see attachmentThumbnailToLinkImg
+ * @param {jQuery} $editable
+ */
+function linkImgToAttachmentThumbnail($editable) {
+ $editable.find('a[href*="/web/content/"][data-mimetype] > img').remove();
+}
+
+
+//--------------------------------------------------------------------------
+//--------------------------------------------------------------------------
+
+
+FieldHtml.include({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ commitChanges: function () {
+ if (this.nodeOptions['style-inline'] && this.mode === "edit") {
+ 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({'style-inline': true});
+ $editable.html(html);
+
+ attachmentThumbnailToLinkImg($editable);
+ fontToImg($editable);
+ 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';
+ });
+ });
+
+ 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);
+
+ // fix outlook image rendering bug
+ $editable.find('img[style*="width"], img[style*="height"]').removeAttr('height width');
+
+ this.wysiwyg.setValue($editable.html(), {
+ notifyChange: false,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onLoadWysiwyg: function () {
+ if (this.nodeOptions['style-inline'] && this.mode === "edit") {
+ this._fromInline();
+ }
+ this._super();
+ },
+});
+
+return {
+ fontToImg: fontToImg,
+ imgToFont: imgToFont,
+ classToStyle: classToStyle,
+ styleToClass: styleToClass,
+ attachmentThumbnailToLinkImg: attachmentThumbnailToLinkImg,
+ linkImgToAttachmentThumbnail: linkImgToAttachmentThumbnail,
+};
+}); \ No newline at end of file
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 00000000..37f2a8ba
--- /dev/null
+++ b/addons/web_editor/static/src/js/backend/field_html.js
@@ -0,0 +1,536 @@
+odoo.define('web_editor.field.html', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var basic_fields = require('web.basic_fields');
+var config = require('web.config');
+var core = require('web.core');
+var Wysiwyg = require('web_editor.wysiwyg.root');
+var field_registry = require('web.field_registry');
+// must wait for web/ to add the default html widget, otherwise it would override the web_editor one
+require('web._field_registry');
+
+var _lt = core._lt;
+var TranslatableFieldMixin = basic_fields.TranslatableFieldMixin;
+var QWeb = core.qweb;
+var assetsLoaded;
+
+var jinjaRegex = /(^|\n)\s*%\s(end|set\s)/;
+
+/**
+ * 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, {
+ description: _lt("Html"),
+ 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 () {
+ var self = this;
+ this.isRendered = false;
+ this._onUpdateIframeId = 'onLoad_' + _.uniqueId('FieldHtml');
+ var defAsset;
+ if (this.nodeOptions.cssReadonly) {
+ defAsset = ajax.loadAsset(this.nodeOptions.cssReadonly);
+ }
+
+ if (!assetsLoaded) { // avoid flickering when begin to edit
+ assetsLoaded = new Promise(function (resolve) {
+ var wysiwyg = new Wysiwyg(self, {});
+ wysiwyg.attachTo($('<textarea>')).then(function () {
+ wysiwyg.destroy();
+ resolve();
+ });
+ });
+ }
+
+ return Promise.all([this._super(), assetsLoaded, 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 (config.isDebug() && this.mode === 'edit') {
+ var layoutInfo = $.summernote.core.dom.makeLayoutInfo(this.wysiwyg.$editor);
+ $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo, false);
+ }
+ if (this.mode == "readonly" || !this.isRendered) {
+ return this._super();
+ }
+ var _super = this._super.bind(this);
+ return this.wysiwyg.saveModifiedImages(this.$content).then(function () {
+ return self.wysiwyg.save(self.nodeOptions).then(function (result) {
+ self._isDirty = result.isDirty;
+ _super();
+ });
+ });
+ },
+ /**
+ * @override
+ */
+ isSet: function () {
+ var value = this.value && this.value.split('&nbsp;').join('').replace(/\s/g, ''); // Removing spaces & html spaces
+ return value && value !== "<p></p>" && value !== "<p><br></p>" && 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 Promise.resolve();
+ },
+
+ //--------------------------------------------------------------------------
+ // 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());
+ this.wysiwyg.__extraAssetsForIframe = this.__extraAssetsForIframe || [];
+
+ // 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.$editor.closest('body, odoo-wysiwyg-container');
+ self._onLoadWysiwyg();
+ self.isRendered = true;
+ });
+ },
+ /**
+ * Get wysiwyg options to create wysiwyg instance.
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getWysiwygOptions: function () {
+ var self = this;
+ return Object.assign({}, this.nodeOptions, {
+ 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,
+ height: 180,
+ generateOptions: function (options) {
+ var toolbar = options.toolbar || options.airPopover || {};
+ var para = _.find(toolbar, function (item) {
+ return item[0] === 'para';
+ });
+ if (para && para[1] && para[1].indexOf('checklist') === -1) {
+ para[1].splice(2, 0, 'checklist');
+ }
+ if (config.isDebug()) {
+ options.codeview = true;
+ var view = _.find(toolbar, function (item) {
+ return item[0] === 'view';
+ });
+ if (view) {
+ if (!view[1].includes('codeview')) {
+ view[1].splice(-1, 0, 'codeview');
+ }
+ } else {
+ toolbar.splice(-1, 0, ['view', ['codeview']]);
+ }
+ }
+ if (self.model === "mail.compose.message" || self.model === "mailing.mailing") {
+ options.noVideos = true;
+ }
+ options.prettifyHtml = false;
+ 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 resolver;
+ var def = new Promise(function (resolve) {
+ resolver = resolve;
+ });
+ 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('loadDef', 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);
+ resolver();
+ };
+
+ this.$iframe.on('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);
+ resolver();
+ }
+
+ 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 (jinjaRegex.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));
+ });
+ },
+ /**
+ * Allows Enter keypress in a textarea (source mode)
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.ENTER) {
+ ev.stopPropagation();
+ return;
+ }
+ this._super.apply(this, arguments);
+ },
+ /**
+ * Method called when wysiwyg triggers a change.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onReadonlyClickChecklist: function (ev) {
+ var self = this;
+ if (ev.offsetX > 0) {
+ return;
+ }
+ ev.stopPropagation();
+ ev.preventDefault();
+ 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',
+ top: '+5px',
+ });
+ this.$el.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/base.js b/addons/web_editor/static/src/js/base.js
new file mode 100644
index 00000000..7d807114
--- /dev/null
+++ b/addons/web_editor/static/src/js/base.js
@@ -0,0 +1,173 @@
+odoo.define('web_editor.base', function (require) {
+'use strict';
+
+// TODO this should be re-removed as soon as possible.
+
+var ajax = require('web.ajax');
+var session = require('web.session');
+
+var domReady = new Promise(function(resolve) {
+ $(resolve);
+});
+
+return {
+ /**
+ * Retrieves all the CSS rules which match the given parser (Regex).
+ *
+ * @param {Regex} filter
+ * @returns {Object[]} Array of CSS rules descriptions (objects). A rule is
+ * defined by 3 values: 'selector', 'css' and 'names'. 'selector'
+ * is a string which contains the whole selector, 'css' is a string
+ * which contains the css properties and 'names' is an array of the
+ * first captured groups for each selector part. E.g.: if the
+ * filter is set to match .fa-* rules and capture the icon names,
+ * the rule:
+ * '.fa-alias1::before, .fa-alias2::before { hello: world; }'
+ * will be retrieved as
+ * {
+ * selector: '.fa-alias1::before, .fa-alias2::before',
+ * css: 'hello: world;',
+ * names: ['.fa-alias1', '.fa-alias2'],
+ * }
+ */
+ cacheCssSelectors: {},
+ getCssSelectors: function (filter) {
+ if (this.cacheCssSelectors[filter]) {
+ return this.cacheCssSelectors[filter];
+ }
+ this.cacheCssSelectors[filter] = [];
+ var sheets = document.styleSheets;
+ for (var i = 0; i < sheets.length; i++) {
+ var rules;
+ try {
+ // try...catch because Firefox not able to enumerate
+ // document.styleSheets[].cssRules[] for cross-domain
+ // stylesheets.
+ rules = sheets[i].rules || sheets[i].cssRules;
+ } catch (e) {
+ console.warn("Can't read the css rules of: " + sheets[i].href, e);
+ continue;
+ }
+ if (!rules) {
+ continue;
+ }
+
+ for (var r = 0 ; r < rules.length ; r++) {
+ var selectorText = rules[r].selectorText;
+ if (!selectorText) {
+ continue;
+ }
+ var selectors = selectorText.split(/\s*,\s*/);
+ var data = null;
+ for (var s = 0; s < selectors.length; s++) {
+ var match = selectors[s].trim().match(filter);
+ if (!match) {
+ continue;
+ }
+ if (!data) {
+ data = {
+ selector: match[0],
+ css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''),
+ names: [match[1]]
+ };
+ } else {
+ data.selector += (', ' + match[0]);
+ data.names.push(match[1]);
+ }
+ }
+ if (data) {
+ this.cacheCssSelectors[filter].push(data);
+ }
+ }
+ }
+ return this.cacheCssSelectors[filter];
+ },
+ /**
+ * List of font icons to load by editor. The icons are displayed in the media
+ * editor and identified like font and image (can be colored, spinned, resized
+ * with fa classes).
+ * To add font, push a new object {base, parser}
+ *
+ * - base: class who appear on all fonts
+ * - parser: regular expression used to select all font in css stylesheets
+ *
+ * @type Array
+ */
+ fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}],
+ /**
+ * Searches the fonts described by the @see fontIcons variable.
+ */
+ computeFonts: _.once(function () {
+ var self = this;
+ _.each(this.fontIcons, function (data) {
+ data.cssData = self.getCssSelectors(data.parser);
+ 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 Promise
+ * 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 {Promise}
+ */
+ ready: function () {
+ return Promise.all([domReady, session.is_bound, ajax.loadXML()]);
+ },
+};
+});
+
+//==============================================================================
+
+odoo.define('web_editor.context', function (require) {
+'use strict';
+
+// TODO this should be re-removed as soon as possible.
+
+function getContext(context) {
+ var html = document.documentElement;
+ return _.extend({
+ lang: (html.getAttribute('lang') || 'en_US').replace('-', '_'),
+
+ // Unfortunately this is a mention of 'website' in 'web_editor' as there
+ // was no other way to do it as this was restored in a stable version.
+ // Indeed, the editor is currently using this context at the root of JS
+ // module, so there is no way for website to hook itself before
+ // web_editor uses it (without a risky refactoring of web_editor in
+ // stable). As mentioned above, the editor should not use this context
+ // anymore anyway (this was restored by the saas-12.2 editor revert).
+ 'website_id': html.getAttribute('data-website-id') | 0,
+ }, 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';
+
+// TODO this should be re-removed as soon as possible.
+
+var base = require('web_editor.base');
+
+return base.ready();
+});
diff --git a/addons/web_editor/static/src/js/common/ace.js b/addons/web_editor/static/src/js/common/ace.js
new file mode 100644
index 00000000..0c3198de
--- /dev/null
+++ b/addons/web_editor/static/src/js/common/ace.js
@@ -0,0 +1,944 @@
+odoo.define('web_editor.ace', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var config = require('web.config');
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var dom = require('web.dom');
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+var localStorage = require('web.local_storage');
+
+var _t = core._t;
+
+/**
+ * Formats a content-check result (@see checkXML, checkSCSS).
+ *
+ * @param {boolean} isValid
+ * @param {integer} [errorLine] needed if isValid is false
+ * @param {string} [errorMessage] needed if isValid is false
+ * @returns {Object}
+ */
+function _getCheckReturn(isValid, errorLine, errorMessage) {
+ return {
+ isValid: isValid,
+ error: isValid ? null : {
+ line: errorLine,
+ message: errorMessage,
+ },
+ };
+}
+/**
+ * Checks the syntax validity of some XML.
+ *
+ * @param {string} xml
+ * @returns {Object} @see _getCheckReturn
+ */
+function checkXML(xml) {
+ if (typeof window.DOMParser != 'undefined') {
+ var xmlDoc = (new window.DOMParser()).parseFromString(xml, 'text/xml');
+ var error = xmlDoc.getElementsByTagName('parsererror');
+ if (error.length > 0) {
+ return _getCheckReturn(false, parseInt(error[0].innerHTML.match(/[Ll]ine[^\d]+(\d+)/)[1], 10), error[0].innerHTML);
+ }
+ } else if (typeof window.ActiveXObject != 'undefined' && new window.ActiveXObject('Microsoft.XMLDOM')) {
+ var xmlDocIE = new window.ActiveXObject('Microsoft.XMLDOM');
+ xmlDocIE.async = 'false';
+ xmlDocIE.loadXML(xml);
+ if (xmlDocIE.parseError.line > 0) {
+ return _getCheckReturn(false, xmlDocIE.parseError.line, xmlDocIE.parseError.reason);
+ }
+ }
+ return _getCheckReturn(true);
+}
+/**
+ * Formats some XML so that it has proper indentation and structure.
+ *
+ * @param {string} xml
+ * @returns {string} formatted xml
+ */
+function formatXML(xml) {
+ // do nothing if an inline script is present to avoid breaking it
+ if (/<script(?: [^>]*)?>[^<][\s\S]*<\/script>/i.test(xml)) {
+ return xml;
+ }
+ return window.vkbeautify.xml(xml, 4);
+}
+/**
+ * Checks the syntax validity of some SCSS.
+ *
+ * @param {string} scss
+ * @returns {Object} @see _getCheckReturn
+ */
+var checkSCSS = (function () {
+ var mapping = {
+ '{': '}', '}': '{',
+ '(': ')', ')': '(',
+ '[': ']', ']': '[',
+ };
+ var openings = ['{', '(', '['];
+ var closings = ['}', ')', ']'];
+
+ return function (scss) {
+ var stack = [];
+ var line = 1;
+ for (var i = 0 ; i < scss.length ; i++) {
+ if (_.contains(openings, scss[i])) {
+ stack.push(scss[i]);
+ } else if (_.contains(closings, scss[i])) {
+ if (stack.pop() !== mapping[scss[i]]) {
+ return _getCheckReturn(false, line, _t("Unexpected ") + scss[i]);
+ }
+ } else if (scss[i] === '\n') {
+ line++;
+ }
+ }
+ if (stack.length > 0) {
+ return _getCheckReturn(false, line, _t("Expected ") + mapping[stack.pop()]);
+ }
+ return _getCheckReturn(true);
+ };
+})();
+/**
+ * Formats some SCSS so that it has proper indentation and structure.
+ *
+ * @todo Right now, this does return the given SCSS content, untouched.
+ * @param {string} scss
+ * @returns {string} formatted scss
+ */
+function formatSCSS(scss) {
+ return scss;
+}
+
+/**
+ * Allows to visualize resources (by default, XML views) and edit them.
+ */
+var ViewEditor = Widget.extend({
+ template: 'web_editor.ace_view_editor',
+ xmlDependencies: ['/web_editor/static/src/xml/ace.xml'],
+ jsLibs: [
+ '/web/static/lib/ace/ace.js',
+ [
+ '/web/static/lib/ace/javascript_highlight_rules.js',
+ '/web/static/lib/ace/mode-xml.js',
+ '/web/static/lib/ace/mode-scss.js',
+ '/web/static/lib/ace/mode-js.js',
+ '/web/static/lib/ace/theme-monokai.js'
+ ]
+ ],
+ events: {
+ 'click .o_ace_type_switcher_choice': '_onTypeChoice',
+ 'change .o_res_list': '_onResChange',
+ 'click .o_ace_filter': '_onFilterChange',
+ 'click button[data-action=save]': '_onSaveClick',
+ 'click button[data-action=reset]': '_onResetClick',
+ 'click button[data-action=format]': '_onFormatClick',
+ 'click button[data-action=close]': '_onCloseClick',
+ 'click #ace-view-id > .alert-warning .close': '_onCloseWarningClick'
+ },
+
+ /**
+ * Initializes the parameters so that the ace editor knows which information
+ * it has to load.
+ *
+ * @constructor
+ * @param {Widget} parent
+ * @param {string|integer} viewKey
+ * xml_id or id of the view whose linked resources have to be loaded.
+ * @param {Object} [options]
+ * @param {string|integer} [options.initialResID]
+ * a specific view ID / SCSS URL to load on start (otherwise the main
+ * view ID associated with the specified viewKey will be used)
+ * @param {string} [options.position=right]
+ * @param {boolean} [options.doNotLoadViews=false]
+ * @param {boolean} [options.doNotLoadSCSS=false]
+ * @param {boolean} [options.doNotLoadJS=false]
+ * @param {boolean} [options.includeBundles=false]
+ * @param {string} [options.filesFilter=custom]
+ * @param {string[]} [options.defaultBundlesRestriction]
+ */
+ init: function (parent, viewKey, options) {
+ this._super.apply(this, arguments);
+
+ this.context = options.context;
+
+ this.viewKey = viewKey;
+ this.options = _.defaults({}, options, {
+ position: 'right',
+ doNotLoadViews: false,
+ doNotLoadSCSS: false,
+ doNotLoadJS: false,
+ includeBundles: false,
+ filesFilter: 'custom',
+ defaultBundlesRestriction: [],
+ });
+
+ this.resources = {xml: {}, scss: {}, js: {}};
+ this.editingSessions = {xml: {}, scss: {}, js: {}};
+ this.currentType = 'xml';
+
+ // Alias
+ this.views = this.resources.xml;
+ this.scss = this.resources.scss;
+ this.js = this.resources.js;
+ },
+ /**
+ * Loads everything the ace library needs to work.
+ * It also loads the resources to visualize (@see _loadResources).
+ *
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([
+ this._super.apply(this, arguments),
+ this._loadResources()
+ ]);
+ },
+ /**
+ * Initializes the library and initial view once the DOM is ready. It also
+ * initializes the resize feature of the ace editor.
+ *
+ * @override
+ */
+ start: function () {
+ this.$viewEditor = this.$('#ace-view-editor');
+
+ this.$typeSwitcherChoices = this.$('.o_ace_type_switcher_choice');
+ this.$typeSwitcherBtn = this.$('.o_ace_type_switcher > .dropdown-toggle');
+
+ this.$lists = {
+ xml: this.$('#ace-view-list'),
+ scss: this.$('#ace-scss-list'),
+ js: this.$('#ace-js-list'),
+ };
+ this.$includeBundlesArea = this.$('.oe_include_bundles');
+ this.$includeAllSCSSArea = this.$('.o_include_all_scss');
+ this.$viewID = this.$('#ace-view-id > span');
+ this.$warningMessage = this.$('#ace-view-id > .alert-warning');
+
+ this.$formatButton = this.$('button[data-action=format]');
+ this.$resetButton = this.$('button[data-action=reset]');
+
+ this.aceEditor = window.ace.edit(this.$viewEditor[0]);
+ this.aceEditor.setTheme('ace/theme/monokai');
+ this.$editor = this.$('.ace_editor');
+
+ var refX = 0;
+ var resizing = false;
+ var minWidth = 400;
+ var debounceStoreEditorWidth = _.debounce(storeEditorWidth, 500);
+
+ this._updateViewSelectDOM();
+
+ var initResID;
+ var initType;
+ if (this.options.initialResID) {
+ initResID = this.options.initialResID;
+ if (_.isString(initResID) && initResID[0] === '/') {
+ if (_.str.endsWith(initResID, '.scss')) {
+ initType = 'scss';
+ } else {
+ initType = 'js';
+ }
+ } else {
+ initType = 'xml';
+ }
+ } else {
+ if (!this.options.doNotLoadSCSS) {
+ initResID = this.sortedSCSS[0][1][0].url; // first bundle, scss files, first one
+ initType = 'scss';
+ }
+ if (!this.options.doNotLoadJS) {
+ initResID = this.sortedJS[0][1][0].url; // first bundle, js files, first one
+ initType = 'js';
+ }
+ if (!this.options.doNotLoadViews) {
+ if (typeof this.viewKey === "number") {
+ initResID = this.viewKey;
+ } else {
+ var view = _.findWhere(this.views, {xml_id: this.viewKey});
+ if (!view) {
+ view = _.findWhere(this.views, {key: this.viewKey});
+ }
+ initResID = view.id;
+ }
+ initType = 'xml';
+ }
+ }
+ if (initResID) {
+ this._displayResource(initResID, initType);
+ }
+
+ if (!this.sortedViews.length || !this.sortedSCSS.length) {
+ _.defer((function () {
+ this._switchType(this.sortedViews.length ? 'xml' : 'scss');
+ this.$typeSwitcherBtn.parent('.btn-group').addClass('d-none');
+ }).bind(this));
+ }
+
+ $(document).on('mouseup.ViewEditor', stopResizing.bind(this)).on('mousemove.ViewEditor', updateWidth.bind(this));
+ if (this.options.position === 'left') {
+ this.$('.ace_scroller').after($('<div>').addClass('ace_resize_bar'));
+ this.$('.ace_gutter').css({'cursor': 'default'});
+ this.$el.on('mousedown.ViewEditor', '.ace_resize_bar', startResizing.bind(this));
+ } else {
+ this.$el.on('mousedown.ViewEditor', '.ace_gutter', startResizing.bind(this));
+ }
+
+ resizeEditor.call(this, readEditorWidth.call(this));
+
+ return this._super.apply(this, arguments);
+
+ function resizeEditor(target) {
+ var width = Math.min(document.body.clientWidth, Math.max(parseInt(target, 10), minWidth));
+ this.$editor.width(width);
+ this.aceEditor.resize();
+ this.$el.width(width);
+ }
+ function storeEditorWidth() {
+ localStorage.setItem('ace_editor_width', this.$el.width());
+ }
+ function readEditorWidth() {
+ var width = localStorage.getItem('ace_editor_width');
+ return parseInt(width || 720, 10);
+ }
+ function startResizing(e) {
+ refX = e.pageX;
+ resizing = true;
+ }
+ function stopResizing() {
+ resizing = false;
+ }
+ function updateWidth(e) {
+ if (!resizing) return;
+
+ var offset = e.pageX - refX;
+ if (this.options.position === 'left') {
+ offset = - offset;
+ }
+ var width = this.$el.width() - offset;
+ refX = e.pageX;
+ resizeEditor.call(this, width);
+ debounceStoreEditorWidth.call(this);
+ }
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ this.$el.off('.ViewEditor');
+ $(document).off('.ViewEditor');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Initializes a text editor for the specified resource.
+ *
+ * @private
+ * @param {integer|string} resID - the ID/URL of the view/scss/js file
+ * @param {string} [type] (default to the currently selected one)
+ * @returns {ace.EditSession}
+ */
+ _buildEditingSession: function (resID, type) {
+ var self = this;
+ type = type || this.currentType;
+ var editingSession = new window.ace.EditSession(this.resources[type][resID].arch);
+ editingSession.setUseWorker(false);
+ editingSession.setMode('ace/mode/' + (type || this.currentType));
+ editingSession.setUndoManager(new window.ace.UndoManager());
+ editingSession.on('change', function () {
+ _.defer(function () {
+ self._toggleDirtyInfo(resID);
+ self._showErrorLine();
+ });
+ });
+ return editingSession;
+ },
+ /**
+ * Forces the view/scss/js file identified by its ID/URL to be displayed in the
+ * editor. The method will update the resource select DOM element as well if
+ * necessary.
+ *
+ * @private
+ * @param {integer|string} resID
+ * @param {string} [type] - the type of resource (either 'xml', 'scss' or 'js')
+ */
+ _displayResource: function (resID, type) {
+ if (type) {
+ this._switchType(type);
+ }
+
+ var editingSession = this.editingSessions[this.currentType][resID];
+ if (!editingSession) {
+ editingSession = this.editingSessions[this.currentType][resID] = this._buildEditingSession(resID);
+ }
+ this.aceEditor.setSession(editingSession);
+
+ if (this.currentType === 'xml') {
+ this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].key));
+ } else if (this.currentType === 'scss') {
+ this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID));
+ } else {
+ this.$viewID.text(_.str.sprintf(_t("JS file: %s"), resID));
+ }
+ const isCustomized = this._isCustomResource(resID);
+ this.$lists[this.currentType].select2('val', resID);
+
+ this.$resetButton.toggleClass('d-none', this.currentType === 'xml' || !isCustomized);
+
+ this.$warningMessage.toggleClass('d-none',
+ this.currentType !== 'xml' && (resID.indexOf('/user_custom_') >= 0) || isCustomized);
+
+ this.aceEditor.resize(true);
+ },
+ /**
+ * Formats the current resource being vizualized.
+ * (@see formatXML, formatSCSS)
+ *
+ * @private
+ */
+ _formatResource: function () {
+ var res = this.aceEditor.getValue();
+ var check = (this.currentType === 'xml' ? checkXML : checkSCSS)(res);
+ if (check.isValid) {
+ this.aceEditor.setValue((this.currentType === 'xml' ? formatXML : formatSCSS)(res));
+ } else {
+ this._showErrorLine(check.error.line, check.error.message, this._getSelectedResource());
+ }
+ },
+ /**
+ * Returns the currently selected resource data.
+ *
+ * @private
+ * @returns {integer|string} view ID or scss file URL
+ */
+ _getSelectedResource: function () {
+ var value = this.$lists[this.currentType].select2('val');
+ return parseInt(value, 10) || value;
+ },
+ /**
+ * Checks resource is customized or not.
+ *
+ * @private
+ * @param {integer|string} resID
+ */
+ _isCustomResource(resID) {
+ // TODO we should be able to detect if the XML template is customized
+ // to not show the warning in that case
+ let isCustomized = false;
+ if (this.currentType === 'scss') {
+ isCustomized = this.scss[resID].customized;
+ } else if (this.currentType === 'js') {
+ isCustomized = this.js[resID].customized;
+ }
+ return isCustomized;
+ },
+ /**
+ * Loads data the ace editor will vizualize and process it. Default behavior
+ * is loading the activate views, index them and build their hierarchy.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _loadResources: function () {
+ // Reset resources
+ this.resources = {xml: {}, scss: {}, js: {}};
+ this.editingSessions = {xml: {}, scss: {}, js: {}};
+ this.views = this.resources.xml;
+ this.scss = this.resources.scss;
+ this.js = this.resources.js;
+
+ // Load resources
+ return this._rpc({
+ route: '/web_editor/get_assets_editor_resources',
+ params: {
+ key: this.viewKey,
+ get_views: !this.options.doNotLoadViews,
+ get_scss: !this.options.doNotLoadSCSS,
+ get_js: !this.options.doNotLoadJS,
+ bundles: this.options.includeBundles,
+ bundles_restriction: this.options.filesFilter === 'all' ? [] : this.options.defaultBundlesRestriction,
+ only_user_custom_files: this.options.filesFilter === 'custom',
+ },
+ }).then((function (resources) {
+ _processViews.call(this, resources.views || []);
+ _processJSorSCSS.call(this, resources.scss || [], 'scss');
+ _processJSorSCSS.call(this, resources.js || [], 'js');
+ }).bind(this));
+
+ function _processViews(views) {
+ // Only keep the active views and index them by ID.
+ _.extend(this.views, _.indexBy(_.filter(views, function (view) {
+ return view.active;
+ }), 'id'));
+
+ // Initialize a 0 level for each view and assign them an array containing their children.
+ var self = this;
+ var roots = [];
+ _.each(this.views, function (view) {
+ view.level = 0;
+ view.children = [];
+ });
+ _.each(this.views, function (view) {
+ var parentId = view.inherit_id[0];
+ var parent = parentId && self.views[parentId];
+ if (parent) {
+ parent.children.push(view);
+ } else {
+ roots.push(view);
+ }
+ });
+
+ // Assign the correct level based on children key and save a sorted array where
+ // each view is followed by their children.
+ this.sortedViews = [];
+ function visit(view, level) {
+ view.level = level;
+ self.sortedViews.push(view);
+ _.each(view.children, function (child) {
+ visit(child, level + 1);
+ });
+ }
+ _.each(roots, function (root) {
+ visit(root, 0);
+ });
+ }
+
+ function _processJSorSCSS(data, type) {
+ // The received scss or js data is already sorted by bundle and DOM order
+ if (type === 'scss') {
+ this.sortedSCSS = data;
+ } else {
+ this.sortedJS = data;
+ }
+
+ // Store the URL ungrouped by bundle and use the URL as key (resource ID)
+ var resources = type === 'scss' ? this.scss : this.js;
+ _.each(data, function (bundleInfos) {
+ _.each(bundleInfos[1], function (info) { info.bundle_xmlid = bundleInfos[0].xmlid; });
+ _.extend(resources, _.indexBy(bundleInfos[1], 'url'));
+ });
+ }
+ },
+ /**
+ * Forces the view/scss/js file identified by its ID/URL to be reset to the way
+ * it was before the user started editing it.
+ *
+ * @todo views reset is not supported yet
+ *
+ * @private
+ * @param {integer|string} [resID] (default to the currently selected one)
+ * @param {string} [type] (default to the currently selected one)
+ * @returns {Promise}
+ */
+ _resetResource: function (resID, type) {
+ resID = resID || this._getSelectedResource();
+ type = type || this.currentType;
+
+ if (this.currentType === 'xml') {
+ return Promise.reject(_t("Reseting views is not supported yet"));
+ } else {
+ var resource = type === 'scss' ? this.scss[resID] : this.js[resID];
+ return this._rpc({
+ route: '/web_editor/reset_asset',
+ params: {
+ url: resID,
+ bundle_xmlid: resource.bundle_xmlid,
+ },
+ });
+ }
+ },
+ /**
+ * Saves a unique SCSS or JS file.
+ *
+ * @private
+ * @param {Object} session - contains the 'id' (url) and the 'text' of the
+ * SCSS or JS file to save.
+ * @return {Promise} status indicates if the save is finished or if an
+ * error occured.
+ */
+ _saveSCSSorJS: function (session) {
+ var self = this;
+ var sessionIdEndsWithJS = _.string.endsWith(session.id, '.js');
+ var bundleXmlID = sessionIdEndsWithJS ? this.js[session.id].bundle_xmlid : this.scss[session.id].bundle_xmlid;
+ var fileType = sessionIdEndsWithJS ? 'js' : 'scss';
+ return self._rpc({
+ route: '/web_editor/save_asset',
+ params: {
+ url: session.id,
+ bundle_xmlid: bundleXmlID,
+ content: session.text,
+ file_type: fileType,
+ },
+ }).then(function () {
+ self._toggleDirtyInfo(session.id, fileType, false);
+ });
+ },
+ /**
+ * Saves every resource that has been modified. If one cannot be saved, none
+ * is saved and an error message is displayed.
+ *
+ * @private
+ * @return {Promise} status indicates if the save is finished or if an
+ * error occured.
+ */
+ _saveResources: function () {
+ var self = this;
+ var toSave = {};
+ var errorFound = false;
+ _.each(this.editingSessions, (function (editingSessions, type) {
+ if (errorFound) return;
+
+ var dirtySessions = _.pick(editingSessions, function (session) {
+ return session.getUndoManager().hasUndo();
+ });
+ toSave[type] = _.map(dirtySessions, function (session, resID) {
+ return {
+ id: parseInt(resID, 10) || resID,
+ text: session.getValue(),
+ };
+ });
+
+ this._showErrorLine();
+ for (var i = 0 ; i < toSave[type].length && !errorFound ; i++) {
+ var check = (type === 'xml' ? checkXML : checkSCSS)(toSave[type][i].text);
+ if (!check.isValid) {
+ this._showErrorLine(check.error.line, check.error.message, toSave[type][i].id, type);
+ errorFound = toSave[type][i];
+ }
+ }
+ }).bind(this));
+ if (errorFound) return Promise.reject(errorFound);
+
+ var defs = [];
+ var mutex = new concurrency.Mutex();
+ _.each(toSave, (function (_toSave, type) {
+ // Child views first as COW on a parent would delete them
+ _toSave = _.sortBy(_toSave, 'id').reverse();
+ _.each(_toSave, function (session) {
+ defs.push(mutex.exec(function () {
+ return (type === 'xml' ? self._saveView(session) : self._saveSCSSorJS(session));
+ }));
+ });
+ }).bind(this));
+
+ var self = this;
+ return Promise.all(defs).guardedCatch(function (results) {
+ // some overrides handle errors themselves
+ if (results === undefined) {
+ return;
+ }
+ var error = results[1];
+ Dialog.alert(self, '', {
+ title: _t("Server error"),
+ $content: $('<div/>').html(
+ _t("A server error occured. Please check you correctly signed in and that the file you are saving is correctly formatted.")
+ + '<br/>'
+ + error
+ )
+ });
+ });
+ },
+ /**
+ * Saves an unique XML view.
+ *
+ * @private
+ * @param {Object} session - the 'id' and the 'text' of the view to save.
+ * @returns {Promise} status indicates if the save is finished or if an
+ * error occured.
+ */
+ _saveView: function (session) {
+ var self = this;
+ return new Promise(function (resolve, reject) {
+ self._rpc({
+ model: 'ir.ui.view',
+ method: 'write',
+ args: [[session.id], {arch: session.text}],
+ }, {
+ noContextKeys: 'lang',
+ }).then(function () {
+ self._toggleDirtyInfo(session.id, 'xml', false);
+ resolve();
+ }, function (source, error) {
+ reject(session, error);
+ });
+ });
+ },
+ /**
+ * Shows a line which produced an error. Red color is added to the editor,
+ * the cursor move to the line and a message is opened on click on the line
+ * number. If called without argument, the effects are removed.
+ *
+ * @private
+ * @param {integer} [line] - the line number to highlight
+ * @param {string} [message] - to show on click on the line number
+ * @param {integer|string} [resID]
+ * @param {string} [type]
+ */
+ _showErrorLine: function (line, message, resID, type) {
+ if (line === undefined || line <= 0) {
+ if (this.$errorLine) {
+ this.$errorLine.removeClass('o_error');
+ this.$errorLine.off('.o_error');
+ this.$errorLine = undefined;
+ this.$errorContent.removeClass('o_error');
+ this.$errorContent = undefined;
+ }
+ return;
+ }
+
+ if (type) this._switchType(type);
+
+ if (this._getSelectedResource() === resID) {
+ __showErrorLine.call(this, line);
+ } else {
+ var onChangeSession = (function () {
+ this.aceEditor.off('changeSession', onChangeSession);
+ _.delay(__showErrorLine.bind(this, line), 400);
+ }).bind(this);
+ this.aceEditor.on('changeSession', onChangeSession);
+ this._displayResource(resID, this.currentType);
+ }
+
+ function __showErrorLine(line) {
+ this.aceEditor.gotoLine(line);
+ this.$errorLine = this.$viewEditor.find('.ace_gutter-cell').filter(function () {
+ return parseInt($(this).text()) === line;
+ }).addClass('o_error');
+ this.$errorLine.addClass('o_error').on('click.o_error', function () {
+ var $message = $('<div/>').html(message);
+ $message.text($message.text());
+ Dialog.alert(this, "", {$content: $message});
+ });
+ this.$errorContent = this.$viewEditor.find('.ace_scroller').addClass('o_error');
+ }
+ },
+ /**
+ * Switches to the SCSS, XML or JS edition. Calling this method will adapt all
+ * DOM elements to keep the editor consistent.
+ *
+ * @private
+ * @param {string} type - either 'xml', 'scss' or 'js'
+ */
+ _switchType: function (type) {
+ this.currentType = type;
+ this.$typeSwitcherBtn.html(this.$typeSwitcherChoices.filter('[data-type=' + type + ']').html());
+ _.each(this.$lists, function ($list, _type) { $list.toggleClass('d-none', type !== _type); });
+ this.$lists[type].change();
+
+ this.$includeBundlesArea.toggleClass('d-none', this.currentType !== 'xml' || !config.isDebug());
+ this.$includeAllSCSSArea.toggleClass('d-none', this.currentType !== 'scss' || !config.isDebug());
+ this.$includeAllSCSSArea.find('[data-value="restricted"]').toggleClass('d-none', this.options.defaultBundlesRestriction.length === 0);
+ this.$formatButton.toggleClass('d-none', this.currentType !== 'xml');
+ },
+ /**
+ * Updates the select option DOM element associated with a particular resID
+ * to indicate if the option is dirty or not.
+ *
+ * @private
+ * @param {integer|string} resID
+ * @param {string} [type] (default to the currently selected one)
+ * @param {boolean} [isDirty] true if the view is dirty, default to content
+ * of UndoManager
+ */
+ _toggleDirtyInfo: function (resID, type, isDirty) {
+ type = type || this.currentType;
+
+ if (!resID || !this.editingSessions[type][resID]) return;
+
+ var $option = this.$lists[type].find('[value="' + resID + '"]');
+ if (isDirty === undefined) {
+ isDirty = this.editingSessions[type][resID].getUndoManager().hasUndo();
+ }
+ $option.data('dirty', isDirty);
+ },
+ /**
+ * Renders the content of the view/file <select/> DOM element according to
+ * current widget data.
+ *
+ * @private
+ */
+ _updateViewSelectDOM: function () {
+ var currentId = this._getSelectedResource();
+
+ var self = this;
+ this.$lists.xml.empty();
+ _.each(this.sortedViews, function (view) {
+ self.$lists.xml.append($('<option/>', {
+ value: view.id,
+ text: view.name,
+ selected: currentId === view.id,
+ 'data-level': view.level,
+ 'data-debug': view.xml_id,
+ }));
+ });
+
+ this.$lists.scss.empty();
+ _populateList(this.sortedSCSS, this.$lists.scss, 5);
+
+ this.$lists.js.empty();
+ _populateList(this.sortedJS, this.$lists.js, 3);
+
+ this.$lists.xml.select2('destroy');
+ this.$lists.xml.select2({
+ formatResult: _formatDisplay.bind(this, false),
+ formatSelection: _formatDisplay.bind(this, true),
+ });
+ this.$lists.xml.data('select2').dropdown.addClass('o_ace_select2_dropdown');
+ this.$lists.scss.select2('destroy');
+ this.$lists.scss.select2({
+ formatResult: _formatDisplay.bind(this, false),
+ formatSelection: _formatDisplay.bind(this, true),
+ });
+ this.$lists.scss.data('select2').dropdown.addClass('o_ace_select2_dropdown');
+ this.$lists.js.select2('destroy');
+ this.$lists.js.select2({
+ formatResult: _formatDisplay.bind(this, false),
+ formatSelection: _formatDisplay.bind(this, true),
+ });
+ this.$lists.js.data('select2').dropdown.addClass('o_ace_select2_dropdown');
+
+ function _populateList(sortedData, $list, lettersToRemove) {
+ _.each(sortedData, function (bundleInfos) {
+ var $optgroup = $('<optgroup/>', {
+ label: bundleInfos[0].name,
+ }).appendTo($list);
+ _.each(bundleInfos[1], function (dataInfo) {
+ var name = dataInfo.url.substring(_.lastIndexOf(dataInfo.url, '/') + 1, dataInfo.url.length - lettersToRemove);
+ $optgroup.append($('<option/>', {
+ value: dataInfo.url,
+ text: name,
+ selected: currentId === dataInfo.url,
+ 'data-debug': dataInfo.url,
+ 'data-customized': dataInfo.customized
+ }));
+ });
+ });
+ }
+
+ function _formatDisplay(isSelected, data) {
+ var $elem = $(data.element);
+
+ var text = data.text || '';
+ if (!isSelected) {
+ text = Array(($elem.data('level') || 0) + 1).join('-') + ' ' + text;
+ }
+ var $div = $('<div/>', {
+ text: text,
+ class: 'o_ace_select2_result',
+ });
+
+ if ($elem.data('dirty') || $elem.data('customized')) {
+ $div.prepend($('<span/>', {
+ class: 'mr8 fa fa-floppy-o ' + ($elem.data('dirty') ? 'text-warning' : 'text-success'),
+ }));
+ }
+
+ if (!isSelected && config.isDebug() && $elem.data('debug')) {
+ $div.append($('<span/>', {
+ text: ' (' + $elem.data('debug') + ')',
+ class: 'ml4 small text-muted',
+ }));
+ }
+
+ return $div;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the close button is clicked -> hides the ace editor.
+ *
+ * @private
+ */
+ _onCloseClick: function () {
+ this.do_hide();
+ },
+ /**
+ * Called when the format button is clicked -> format the current resource.
+ *
+ * @private
+ */
+ _onFormatClick: function () {
+ this._formatResource();
+ },
+ /**
+ * Called when a filter dropdown item is cliked. Reload the resources
+ * according to the new filter and make it visually active.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onFilterChange: function (ev) {
+ var $item = $(ev.target);
+ $item.addClass('active').siblings().removeClass('active');
+ if ($item.data('type') === 'xml') {
+ this.options.includeBundles = $(ev.target).data('value') === 'all';
+ } else {
+ this.options.filesFilter = $item.data('value');
+ }
+ this._loadResources().then(this._updateViewSelectDOM.bind(this));
+ },
+ /**
+ * Called when another resource is selected -> displays it.
+ *
+ * @private
+ */
+ _onResChange: function () {
+ this._displayResource(this._getSelectedResource());
+ },
+ /**
+ * Called when the reset button is clicked -> resets the resources to its
+ * original standard odoo state.
+ *
+ * @private
+ */
+ _onResetClick: function () {
+ var self = this;
+ Dialog.confirm(this, _t("If you reset this file, all your customizations will be lost as it will be reverted to the default file."), {
+ title: _t("Careful !"),
+ confirm_callback: function () {
+ self._resetResource(self._getSelectedResource());
+ },
+ });
+ },
+ /**
+ * Called when the save button is clicked -> saves the dirty resources and
+ * reloads.
+ *
+ * @private
+ */
+ _onSaveClick: function (ev) {
+ const restore = dom.addButtonLoadingEffect(ev.currentTarget);
+ this._saveResources().then(restore).guardedCatch(restore);
+ },
+ /**
+ * Called when the user wants to switch from xml to scss or vice-versa ->
+ * adapt resources choices and displays a resource of that type.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onTypeChoice: function (ev) {
+ ev.preventDefault();
+ this._switchType($(ev.target).data('type'));
+ },
+ /**
+ * Allows to hide the warning message without removing it from the DOM
+ * -> by default Bootstrap removes alert from the DOM
+ */
+ _onCloseWarningClick: function () {
+ this.$warningMessage.addClass('d-none');
+ },
+});
+
+return ViewEditor;
+});
diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js
new file mode 100644
index 00000000..1cd10318
--- /dev/null
+++ b/addons/web_editor/static/src/js/common/utils.js
@@ -0,0 +1,266 @@
+odoo.define('web_editor.utils', function (require) {
+'use strict';
+
+const {ColorpickerWidget} = require('web.Colorpicker');
+
+/**
+ * window.getComputedStyle cannot work properly with CSS shortcuts (like
+ * 'border-width' which is a shortcut for the top + right + bottom + left border
+ * widths. If an option wants to customize such a shortcut, it should be listed
+ * here with the non-shortcuts property it stands for, in order.
+ *
+ * @type {Object<string[]>}
+ */
+const CSS_SHORTHANDS = {
+ 'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'],
+ 'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'],
+ 'border-color': ['border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color'],
+ 'border-style': ['border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style'],
+};
+/**
+ * Key-value mapping to list converters from an unit A to an unit B.
+ * - The key is a string in the format '$1-$2' where $1 is the CSS symbol of
+ * unit A and $2 is the CSS symbol of unit B.
+ * - The value is a function that converts the received value (expressed in
+ * unit A) to another value expressed in unit B. Two other parameters is
+ * received: the css property on which the unit applies and the jQuery element
+ * on which that css property may change.
+ */
+const CSS_UNITS_CONVERSION = {
+ 's-ms': () => 1000,
+ 'ms-s': () => 0.001,
+ 'rem-px': () => _computePxByRem(),
+ 'px-rem': () => _computePxByRem(true),
+};
+/**
+ * Colors of the default palette, used for substitution in shapes/illustrations.
+ * key: number of the color in the palette (ie, o-color-<1-5>)
+ * value: color hex code
+ */
+const DEFAULT_PALETTE = {
+ '1': '#3AADAA',
+ '2': '#7C6576',
+ '3': '#F6F6F6',
+ '4': '#FFFFFF',
+ '5': '#383E45',
+};
+
+/**
+ * Computes the number of "px" needed to make a "rem" unit. Subsequent calls
+ * returns the cached computed value.
+ *
+ * @param {boolean} [toRem=false]
+ * @returns {float} - number of px by rem if 'toRem' is false
+ * - the inverse otherwise
+ */
+function _computePxByRem(toRem) {
+ if (_computePxByRem.PX_BY_REM === undefined) {
+ const htmlStyle = window.getComputedStyle(document.documentElement);
+ _computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']);
+ }
+ return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM;
+}
+/**
+ * Converts the given (value + unit) string to a numeric value expressed in
+ * the other given css unit.
+ *
+ * e.g. fct('400ms', 's') -> 0.4
+ *
+ * @param {string} value
+ * @param {string} unitTo
+ * @param {string} [cssProp] - the css property on which the unit applies
+ * @param {jQuery} [$target] - the jQuery element on which that css property
+ * may change
+ * @returns {number}
+ */
+function _convertValueToUnit(value, unitTo, cssProp, $target) {
+ const m = _getNumericAndUnit(value);
+ if (!m) {
+ return NaN;
+ }
+ const numValue = parseFloat(m[0]);
+ const valueUnit = m[1];
+ return _convertNumericToUnit(numValue, valueUnit, unitTo, cssProp, $target);
+}
+/**
+ * Converts the given numeric value expressed in the given css unit into
+ * the corresponding numeric value expressed in the other given css unit.
+ *
+ * e.g. fct(400, 'ms', 's') -> 0.4
+ *
+ * @param {number} value
+ * @param {string} unitFrom
+ * @param {string} unitTo
+ * @param {string} [cssProp] - the css property on which the unit applies
+ * @param {jQuery} [$target] - the jQuery element on which that css property
+ * may change
+ * @returns {number}
+ */
+function _convertNumericToUnit(value, unitFrom, unitTo, cssProp, $target) {
+ if (Math.abs(value) < Number.EPSILON || unitFrom === unitTo) {
+ return value;
+ }
+ const converter = CSS_UNITS_CONVERSION[`${unitFrom}-${unitTo}`];
+ if (converter === undefined) {
+ throw new Error(`Cannot convert '${unitFrom}' units into '${unitTo}' units !`);
+ }
+ return value * converter(cssProp, $target);
+}
+/**
+ * Returns the numeric value and unit of a css value.
+ *
+ * e.g. fct('400ms') -> [400, 'ms']
+ *
+ * @param {string} value
+ * @returns {Array|null}
+ */
+function _getNumericAndUnit(value) {
+ const m = value.trim().match(/^(-?[0-9.]+)([A-Za-z% -]*)$/);
+ if (!m) {
+ return null;
+ }
+ return [m[1].trim(), m[2].trim()];
+}
+/**
+ * Checks if two css values are equal.
+ *
+ * @param {string} value1
+ * @param {string} value2
+ * @param {string} [cssProp] - the css property on which the unit applies
+ * @param {jQuery} [$target] - the jQuery element on which that css property
+ * may change
+ * @returns {boolean}
+ */
+function _areCssValuesEqual(value1, value2, cssProp, $target) {
+ // String comparison first
+ if (value1 === value2) {
+ return true;
+ }
+
+ // It could be a CSS variable, in that case the actual value has to be
+ // retrieved before comparing.
+ if (value1.startsWith('var(--')) {
+ value1 = _getCSSVariableValue(value1.substring(6, value1.length - 1));
+ }
+ if (value2.startsWith('var(--')) {
+ value2 = _getCSSVariableValue(value2.substring(6, value2.length - 1));
+ }
+ if (value1 === value2) {
+ return true;
+ }
+
+ // They may be colors, normalize then re-compare the resulting string
+ const color1 = ColorpickerWidget.normalizeCSSColor(value1);
+ const color2 = ColorpickerWidget.normalizeCSSColor(value2);
+ if (color1 === color2) {
+ return true;
+ }
+
+ // Convert the second value in the unit of the first one and compare
+ // floating values
+ const data = _getNumericAndUnit(value1);
+ if (!data) {
+ return false;
+ }
+ const numValue1 = data[0];
+ const numValue2 = _convertValueToUnit(value2, data[1], cssProp, $target);
+ return (Math.abs(numValue1 - numValue2) < Number.EPSILON);
+}
+/**
+ * @param {string|number} name
+ * @returns {boolean}
+ */
+function _isColorCombinationName(name) {
+ const number = parseInt(name);
+ return (!isNaN(number) && number % 100 !== 0);
+}
+/**
+ * @param {string[]} colorNames
+ * @param {string} [prefix='bg-']
+ * @returns {string[]}
+ */
+function _computeColorClasses(colorNames, prefix = 'bg-') {
+ let hasCCClasses = false;
+ const isBgPrefix = (prefix === 'bg-');
+ const classes = colorNames.map(c => {
+ if (isBgPrefix && _isColorCombinationName(c)) {
+ hasCCClasses = true;
+ return `o_cc${c}`;
+ }
+ return (prefix + c);
+ });
+ if (hasCCClasses) {
+ classes.push('o_cc');
+ }
+ return classes;
+}
+/**
+ * @param {string} key
+ * @param {CSSStyleDeclaration} [htmlStyle] if not provided, it is computed
+ * @returns {string}
+ */
+function _getCSSVariableValue(key, htmlStyle) {
+ if (htmlStyle === undefined) {
+ htmlStyle = window.getComputedStyle(document.documentElement);
+ }
+ // Get trimmed value from the HTML element
+ let value = htmlStyle.getPropertyValue(`--${key}`).trim();
+ // If it is a color value, it needs to be normalized
+ value = ColorpickerWidget.normalizeCSSColor(value);
+ // Normally scss-string values are "printed" single-quoted. That way no
+ // magic conversation is needed when customizing a variable: either save it
+ // quoted for strings or non quoted for colors, numbers, etc. However,
+ // Chrome has the annoying behavior of changing the single-quotes to
+ // double-quotes when reading them through getPropertyValue...
+ return value.replace(/"/g, "'");
+}
+/**
+ * Normalize a color in case it is a variable name so it can be used outside of
+ * css.
+ *
+ * @param {string} color the color to normalize into a css value
+ * @returns {string} the normalized color
+ */
+function _normalizeColor(color) {
+ if (ColorpickerWidget.isCSSColor(color)) {
+ return color;
+ }
+ return _getCSSVariableValue(color);
+}
+/**
+ * Parse an element's background-image's url.
+ *
+ * @param {string} string a css value in the form 'url("...")'
+ * @returns {string|false} the src of the image or false if not parsable
+ */
+function _getBgImageURL(el) {
+ const string = $(el).css('background-image');
+ const match = string.match(/^url\((['"])(.*?)\1\)$/);
+ if (!match) {
+ return '';
+ }
+ const matchedURL = match[2];
+ // Make URL relative if possible
+ const fullURL = new URL(matchedURL, window.location.origin);
+ if (fullURL.origin === window.location.origin) {
+ return fullURL.href.slice(fullURL.origin.length);
+ }
+ return matchedURL;
+}
+
+return {
+ CSS_SHORTHANDS: CSS_SHORTHANDS,
+ CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION,
+ DEFAULT_PALETTE: DEFAULT_PALETTE,
+ computePxByRem: _computePxByRem,
+ convertValueToUnit: _convertValueToUnit,
+ convertNumericToUnit: _convertNumericToUnit,
+ getNumericAndUnit: _getNumericAndUnit,
+ areCssValuesEqual: _areCssValuesEqual,
+ isColorCombinationName: _isColorCombinationName,
+ computeColorClasses: _computeColorClasses,
+ getCSSVariableValue: _getCSSVariableValue,
+ normalizeColor: _normalizeColor,
+ getBgImageURL: _getBgImageURL,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/custom_colors.js b/addons/web_editor/static/src/js/editor/custom_colors.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/custom_colors.js
diff --git a/addons/web_editor/static/src/js/editor/editor.js b/addons/web_editor/static/src/js/editor/editor.js
new file mode 100644
index 00000000..1d6f34aa
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/editor.js
@@ -0,0 +1,289 @@
+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 summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+
+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_editable: '_onRequestEditable',
+ request_history_undo_record: '_onHistoryUndoRecordRequest',
+ request_save: '_onSaveRequest',
+ },
+
+ /**
+ * Initializes RTE and snippets menu.
+ *
+ * @constructor
+ */
+ init: function (parent, options) {
+ var self = this;
+ var res = this._super.apply(this, arguments);
+ var Editor = options.Editor || rte.Class;
+ this.rte = new Editor(this, {
+ getConfig: function ($editable) {
+ var param = self._getDefaultConfig($editable);
+ if (options.generateOptions) {
+ param = options.generateOptions(param);
+ }
+ return param;
+ },
+ saveElement: options.saveElement,
+ });
+ 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
+
+ if (options.snippets) {
+ this.snippetsMenu = new snippetsEditor.Class(this, Object.assign({
+ $el: $editable,
+ selectorEditableArea: '.o_editable',
+ }, options));
+ }
+
+ 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
+ if (self.snippetsMenu) {
+ defs.push(this.snippetsMenu.insertAfter(this.$el));
+ }
+ this.rte.editable().find('*').off('mousedown mouseup click');
+
+ return Promise.all(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 {Promise}
+ */
+ cancel: function (reload) {
+ var self = this;
+ return new Promise(function(resolve, reject) {
+ if (!rte.history.getEditableHasUndo().length) {
+ resolve();
+ } else {
+ var confirm = Dialog.confirm(this, _t("If you discard the current edits, all unsaved changes will be lost. You can cancel to return to edit mode."), {
+ confirm_callback: resolve,
+ });
+ confirm.on('closed', self, reject);
+ }
+ }).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 {Promise}
+ */
+ save: async function (reload) {
+ var defs = [];
+ this.trigger_up('ready_to_save', {defs: defs});
+ await Promise.all(defs);
+
+ if (this.snippetsMenu) {
+ await this.snippetsMenu.cleanForSave();
+ }
+ await this.getParent().saveModifiedImages(this.rte.editable());
+ await this.rte.save();
+
+ if (reload !== false) {
+ return this._reload();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _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,
+ };
+ },
+ /**
+ * Reloads the page in non-editable mode, with the right scrolling.
+ *
+ * @private
+ * @returns {Promise} (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 new Promise(function(){});
+ },
+
+ //--------------------------------------------------------------------------
+ // 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);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRequestEditable: function (ev) {
+ ev.data.callback(this.rte.editable());
+ },
+});
+
+return {
+ Class: EditorMenuBar,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/image_processing.js b/addons/web_editor/static/src/js/editor/image_processing.js
new file mode 100644
index 00000000..e3497634
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/image_processing.js
@@ -0,0 +1,335 @@
+odoo.define('web_editor.image_processing', function (require) {
+'use strict';
+
+// Fields returned by cropperjs 'getData' method, also need to be passed when
+// initializing the cropper to reuse the previous crop.
+const cropperDataFields = ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY'];
+const modifierFields = [
+ 'filter',
+ 'quality',
+ 'mimetype',
+ 'glFilter',
+ 'originalId',
+ 'originalSrc',
+ 'resizeWidth',
+ 'aspectRatio',
+];
+
+// webgl color filters
+const _applyAll = (result, filter, filters) => {
+ filters.forEach(f => {
+ if (f[0] === 'blend') {
+ const cv = f[1];
+ const ctx = result.getContext('2d');
+ ctx.globalCompositeOperation = f[2];
+ ctx.globalAlpha = f[3];
+ ctx.drawImage(cv, 0, 0);
+ ctx.globalCompositeOperation = 'source-over';
+ ctx.globalAlpha = 1.0;
+ } else {
+ filter.addFilter(...f);
+ }
+ });
+};
+let applyAll;
+
+const glFilters = {
+ blur: filter => filter.addFilter('blur', 10),
+
+ '1977': (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(243, 106, 188)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .3],
+ ['brightness', .1],
+ ['contrast', .1],
+ ['saturation', .3],
+ ]);
+ },
+
+ aden: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(66, 10, 14)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'darken', .2],
+ ['brightness', .2],
+ ['contrast', -.1],
+ ['saturation', -.15],
+ ['hue', 20],
+ ]);
+ },
+
+ brannan: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = 'rgb(161, 44, 191)';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'lighten', .31],
+ ['sepia', .5],
+ ['contrast', .4],
+ ]);
+ },
+
+ earlybird: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(.2, '#D0BA8E');
+ gradient.addColorStop(1, '#1D0210');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'overlay', .2],
+ ['sepia', .2],
+ ['contrast', -.1],
+ ]);
+ },
+
+ inkwell: (filter, cv) => {
+ applyAll(filter, [
+ ['sepia', .3],
+ ['brightness', .1],
+ ['contrast', -.1],
+ ['desaturateLuminance'],
+ ]);
+ },
+
+ // Needs hue blending mode for perfect reproduction. Close enough?
+ maven: (filter, cv) => {
+ applyAll(filter, [
+ ['sepia', .25],
+ ['brightness', -.05],
+ ['contrast', -.05],
+ ['saturation', .5],
+ ]);
+ },
+
+ toaster: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(0, '#0F4E80');
+ gradient.addColorStop(1, '#3B003B');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .5],
+ ['brightness', -.1],
+ ['contrast', .5],
+ ]);
+ },
+
+ walden: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = '#CC4400';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'screen', .3],
+ ['sepia', .3],
+ ['brightness', .1],
+ ['saturation', .6],
+ ['hue', 350],
+ ]);
+ },
+
+ valencia: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = '#3A0339';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'exclusion', .5],
+ ['sepia', .08],
+ ['brightness', .08],
+ ['contrast', .08],
+ ]);
+ },
+
+ xpro: (filter, cv) => {
+ const ctx = cv.getContext('2d');
+ const gradient = ctx.createRadialGradient(
+ cv.width / 2, cv.height / 2, 0,
+ cv.width / 2, cv.height / 2, Math.hypot(cv.width, cv.height) / 2
+ );
+ gradient.addColorStop(.4, '#E0E7E6');
+ gradient.addColorStop(1, '#2B2AA1');
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ applyAll(filter, [
+ ['blend', cv, 'color-burn', .7],
+ ['sepia', .3],
+ ]);
+ },
+
+ custom: (filter, cv, filterOptions) => {
+ const options = Object.assign({
+ blend: 'normal',
+ filterColor: '',
+ blur: '0',
+ desaturateLuminance: '0',
+ saturation: '0',
+ contrast: '0',
+ brightness: '0',
+ sepia: '0',
+ }, JSON.parse(filterOptions || "{}"));
+ const filters = [];
+ if (options.filterColor) {
+ const ctx = cv.getContext('2d');
+ ctx.fillStyle = options.filterColor;
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ filters.push(['blend', cv, options.blend, 1]);
+ }
+ delete options.blend;
+ delete options.filterColor;
+ filters.push(...Object.entries(options).map(([filter, amount]) => [filter, parseInt(amount) / 100]));
+ applyAll(filter, filters);
+ },
+};
+/**
+ * Applies data-attributes modifications to an img tag and returns a dataURL
+ * containing the result. This function does not modify the original image.
+ *
+ * @param {HTMLImageElement} img the image to which modifications are applied
+ * @returns {string} dataURL of the image with the applied modifications
+ */
+async function applyModifications(img) {
+ const data = Object.assign({
+ glFilter: '',
+ filter: '#0000',
+ quality: '75',
+ }, img.dataset);
+ let {
+ width,
+ height,
+ resizeWidth,
+ quality,
+ filter,
+ mimetype,
+ originalSrc,
+ glFilter,
+ filterOptions,
+ } = data;
+ [width, height, resizeWidth] = [width, height, resizeWidth].map(s => parseFloat(s));
+ quality = parseInt(quality);
+
+ // Crop
+ const container = document.createElement('div');
+ const original = await loadImage(originalSrc);
+ container.appendChild(original);
+ await activateCropper(original, 0, data);
+ const croppedImg = $(original).cropper('getCroppedCanvas', {width, height});
+ $(original).cropper('destroy');
+
+ // Width
+ const result = document.createElement('canvas');
+ result.width = resizeWidth || croppedImg.width;
+ result.height = croppedImg.height * result.width / croppedImg.width;
+ const ctx = result.getContext('2d');
+ ctx.drawImage(croppedImg, 0, 0, croppedImg.width, croppedImg.height, 0, 0, result.width, result.height);
+
+ // GL filter
+ if (glFilter) {
+ const glf = new window.WebGLImageFilter();
+ const cv = document.createElement('canvas');
+ cv.width = result.width;
+ cv.height = result.height;
+ applyAll = _applyAll.bind(null, result);
+ glFilters[glFilter](glf, cv, filterOptions);
+ const filtered = glf.apply(result);
+ ctx.drawImage(filtered, 0, 0, filtered.width, filtered.height, 0, 0, result.width, result.height);
+ }
+
+ // Color filter
+ ctx.fillStyle = filter || '#0000';
+ ctx.fillRect(0, 0, result.width, result.height);
+
+ // Quality
+ return result.toDataURL(mimetype, quality / 100);
+}
+
+/**
+ * Loads an src into an HTMLImageElement.
+ *
+ * @param {String} src URL of the image to load
+ * @param {HTMLImageElement} [img] img element in which to load the image
+ * @returns {Promise<HTMLImageElement>} Promise that resolves to the loaded img
+ */
+function loadImage(src, img = new Image()) {
+ return new Promise((resolve, reject) => {
+ img.addEventListener('load', () => resolve(img), {once: true});
+ img.addEventListener('error', reject, {once: true});
+ img.src = src;
+ });
+}
+
+// Because cropperjs acquires images through XHRs on the image src and we don't
+// want to load big images over the network many times when adjusting quality
+// and filter, we create a local cache of the images using object URLs.
+const imageCache = new Map();
+/**
+ * Activates the cropper on a given image.
+ *
+ * @param {jQuery} $image the image on which to activate the cropper
+ * @param {Number} aspectRatio the aspectRatio of the crop box
+ * @param {DOMStringMap} dataset dataset containing the cropperDataFields
+ */
+async function activateCropper(image, aspectRatio, dataset) {
+ const src = image.getAttribute('src');
+ if (!imageCache.has(src)) {
+ const res = await fetch(src);
+ imageCache.set(src, URL.createObjectURL(await res.blob()));
+ }
+ image.src = imageCache.get(src);
+ $(image).cropper({
+ viewMode: 2,
+ dragMode: 'move',
+ autoCropArea: 1.0,
+ aspectRatio: aspectRatio,
+ data: _.mapObject(_.pick(dataset, ...cropperDataFields), value => parseFloat(value)),
+ // Can't use 0 because it's falsy and cropperjs will then use its defaults (200x100)
+ minContainerWidth: 1,
+ minContainerHeight: 1,
+ });
+ return new Promise(resolve => image.addEventListener('ready', resolve, {once: true}));
+}
+/**
+ * Marks an <img> with its attachment data (originalId, originalSrc, mimetype)
+ *
+ * @param {HTMLImageElement} img the image whose attachment data should be found
+ * @param {Function} rpc a function that can be used to make the RPC. Typically
+ * this would be passed as 'this._rpc.bind(this)' from widgets.
+ */
+async function loadImageInfo(img, rpc) {
+ const src = img.getAttribute('src');
+ // If there is a marked originalSrc, the data is already loaded.
+ if (img.dataset.originalSrc || !src) {
+ return;
+ }
+
+ const {original} = await rpc({
+ route: '/web_editor/get_image_info',
+ params: {src: src.split(/[?#]/)[0]},
+ });
+ // Check that url is local.
+ const isLocal = original && new URL(original.image_src, window.location.origin).origin === window.location.origin;
+ if (isLocal && original.image_src) {
+ img.dataset.originalId = original.id;
+ img.dataset.originalSrc = original.image_src;
+ img.dataset.mimetype = original.mimetype;
+ }
+}
+
+return {
+ applyModifications,
+ cropperDataFields,
+ activateCropper,
+ loadImageInfo,
+ loadImage,
+ removeOnImageChangeAttrs: [...cropperDataFields, ...modifierFields, 'aspectRatio'],
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js
new file mode 100644
index 00000000..baded863
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/rte.js
@@ -0,0 +1,816 @@
+odoo.define('web_editor.rte', function (require) {
+'use strict';
+
+var fonts = require('wysiwyg.fonts');
+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');
+ var $tempDiv = $('<div/>', {html: oSnap.contents});
+ _.each($tempDiv.find('.o_temp_auto_element'), function (el) {
+ var $el = $(el);
+ var originalContent = $el.attr('data-temp-auto-element-original-content');
+ if (originalContent) {
+ $el.after(originalContent);
+ }
+ $el.remove();
+ });
+ $editable.html($tempDiv.html()).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, params) {
+ 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 = params && params.getConfig || this._getDefaultConfig;
+ this._saveElement = params && params.saveElement || this._saveElement;
+
+ fonts.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', function (ev) {
+ self.trigger_up('rte_change', {target: ev.target});
+
+ // Add the dirty flag to the element that changed by either adding
+ // it on the highest editable ancestor or, if there is no editable
+ // ancestor, on the element itself (that element may not be editable
+ // but if it received a content_changed event, it should be marked
+ // as dirty to allow for custom savings).
+ if (!ev.__isDirtyHandled) {
+ ev.__isDirtyHandled = true;
+
+ var el = ev.target;
+ var dirty = el.closest('.o_editable') || el;
+ dirty.classList.add('o_dirty');
+ }
+ });
+
+ $('#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) {
+ const initialActiveElement = document.activeElement;
+ const initialSelectionStart = initialActiveElement && initialActiveElement.selectionStart;
+ const initialSelectionEnd = initialActiveElement && initialActiveElement.selectionEnd;
+
+ $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);
+
+ if (initialActiveElement && initialActiveElement !== document.activeElement) {
+ initialActiveElement.focus();
+ // Range inputs don't support selection
+ if (initialActiveElement.matches('input[type=range]')) {
+ return;
+ }
+ try {
+ initialActiveElement.selectionStart = initialSelectionStart;
+ initialActiveElement.selectionEnd = initialSelectionEnd;
+ } catch (e) {
+ // The active element might be of a type that
+ // does not support selection.
+ console.log('error', e);
+ }
+ }
+ },
+ /**
+ * 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 {Promise} rejected if the save cannot be done
+ */
+ save: function (context) {
+ var self = this;
+
+ $('.o_editable')
+ .destroy()
+ .removeClass('o_editable o_is_inline_editable o_editable_date_field_linked o_editable_date_field_format_changed');
+
+ 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');
+ }).guardedCatch(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);
+ $('.o_editable.' + id)
+ .removeClass(id)
+ .popover({
+ trigger: 'hover',
+ content: response.message.data.message || '',
+ placement: 'auto top',
+ })
+ .popover('show');
+ });
+ });
+ });
+
+ return Promise.all(defs).then(function () {
+ window.onbeforeunload = null;
+ }).guardedCatch(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') === "datetime" || $editable.data('oe-type') === "date") {
+ var selector = '[data-oe-id="' + $editable.data('oe-id') + '"]';
+ selector += '[data-oe-field="' + $editable.data('oe-field') + '"]';
+ selector += '[data-oe-model="' + $editable.data('oe-model') + '"]';
+ var $linkedFieldNodes = this.editable().find(selector).addBack(selector);
+ $linkedFieldNodes.not($editable).addClass('o_editable_date_field_linked');
+ if (!$editable.hasClass('o_editable_date_field_format_changed')) {
+ $linkedFieldNodes.html($editable.data('oe-original-with-format'));
+ $linkedFieldNodes.addClass('o_editable_date_field_format_changed');
+ }
+ }
+ 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();
+ }
+ if ($editable.data('oe-type') === "image") {
+ $editable.attr('contenteditable', false);
+ $editable.find('img').attr('contenteditable', true);
+ }
+ },
+ /**
+ * 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) {
+ var viewID = $el.data('oe-id');
+ if (!viewID) {
+ return Promise.resolve();
+ }
+
+ return this._rpc({
+ model: 'ir.ui.view',
+ method: 'save',
+ args: [
+ viewID,
+ 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');
+ var isLink = $target.is('a');
+
+ if (this && this.$last && this.$last.length && this.$last[0] !== $target[0]) {
+ $('.o_editable_date_field_linked').removeClass('o_editable_date_field_linked');
+ }
+ if (!$editable.length || (!isLink && $.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 (isLink && !$target.closest('.o_not_editable').length) {
+ /**
+ * 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.
+ */
+ let hasContentEditable = $target.attr('contenteditable');
+ $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;
+ if (!hasContentEditable) {
+ $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();
+ // In some special cases, we need to clear the timeout.
+ var lastTimerId = _.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;
+ // for modal dialogs (eg newsletter popup), when we close the dialog, the modal is
+ // destroyed immediately and so after the delayed execution due to timeout, dialog will
+ // not be available, leading to trace-back, so we need to clearTimeout for the dialogs.
+ if ($destroy.hasClass('modal-body')) {
+ clearTimeout(lastTimerId);
+ }
+ }
+
+ 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 && 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';
+
+// These colors are already normalized as per normalizeCSSColor in web.Colorpicker
+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
new file mode 100644
index 00000000..76d86d47
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/rte.summernote.js
@@ -0,0 +1,1280 @@
+odoo.define('web_editor.rte.summernote', function (require) {
+'use strict';
+
+var Class = require('web.Class');
+const concurrency = require('web.concurrency');
+var core = require('web.core');
+// Use the top window's core.bus for dialog events so that they take the whole window
+// instead of being confined to an iframe. This means that the event triggered on
+// the bus by summernote in an iframe will be caught by the wysiwyg's SummernoteManager
+// outside the iframe.
+const topBus = window.top.odoo.__DEBUG__.services['web.core'].bus;
+const {ColorpickerWidget} = require('web.Colorpicker');
+var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget;
+var mixins = require('web.mixins');
+var fonts = require('wysiwyg.fonts');
+var rte = require('web_editor.rte');
+var ServicesMixin = require('web.ServicesMixin');
+var weWidgets = require('wysiwyg.widgets');
+
+var _t = core._t;
+
+// 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;
+
+// Summernote uses execCommand and, worth, obsolete queryCommandState function
+// to customize the edited content. Here we try to hack the function to solve
+// some problems by making the DOM and style easier to understand for the
+// base function for the duration of their executions. This won't obviously
+// solves all problems but this is an improvement while waiting for the new
+// Odoo editor coming in future versions.
+function protectCommand(callback) {
+ return function () {
+ var rng = range.create();
+ var $sc = (rng && rng.sc) ? $(rng.sc).parents(':o_editable').last() : $();
+ var $ec = (rng && rng.ec) ? $(rng.ec).parents(':o_editable').last() : $();
+ $sc.addClass('o_we_command_protector');
+ $ec.addClass('o_we_command_protector');
+ var restore = function () {
+ $sc.removeClass('o_we_command_protector');
+ $ec.removeClass('o_we_command_protector');
+ };
+ var result;
+ try {
+ result = callback.apply(this, arguments);
+ } catch (err) {
+ restore();
+ throw err;
+ }
+ restore();
+ return result;
+ };
+}
+document.execCommand = protectCommand(document.execCommand);
+document.queryCommandState = protectCommand(document.queryCommandState);
+
+var tplButton = renderer.getTemplate().button;
+var tplIconButton = renderer.getTemplate().iconButton;
+var tplDropdown = renderer.getTemplate().dropdown;
+
+const processAndApplyColor = function (target, eventName, color, preview) {
+ if (!color) {
+ color = 'inherit';
+ } else if (!ColorpickerWidget.isCSSColor(color)) {
+ color = (eventName === "foreColor" ? 'text-' : 'bg-') + color;
+ }
+ var layoutInfo = dom.makeLayoutInfo(target);
+ $.summernote.pluginEvents[eventName](undefined, eventHandler.modules.editor, layoutInfo, color, preview);
+};
+// Update and change the popovers content, and add history button
+renderer.createPalette = function ($container, options) {
+ const $dropdownContent = $container.find(".colorPalette");
+ // The editor's root widget can be website or web's root widget and cannot be properly retrieved...
+ const parent = odoo.__DEBUG__.services['root.widget'];
+ _.each($dropdownContent, elem => {
+ const eventName = elem.dataset.eventName;
+ let colorpicker = null;
+ const mutex = new concurrency.MutexedDropPrevious();
+ const $dropdown = $(elem).closest('.btn-group, .dropdown');
+ let manualOpening = false;
+ // Prevent dropdown closing on colorpicker click
+ $dropdown.on('hide.bs.dropdown', ev => {
+ return !(ev.clickEvent && ev.clickEvent.originalEvent && ev.clickEvent.originalEvent.__isColorpickerClick);
+ });
+ $dropdown.on('show.bs.dropdown', () => {
+ if (manualOpening) {
+ return true;
+ }
+ mutex.exec(() => {
+ const oldColorpicker = colorpicker;
+ const hookEl = oldColorpicker ? oldColorpicker.el : elem;
+
+ const r = range.create();
+ const targetNode = r.sc;
+ const targetElement = targetNode.nodeType === Node.ELEMENT_NODE ? targetNode : targetNode.parentNode;
+ colorpicker = new ColorPaletteWidget(parent, {
+ excluded: ['transparent_grayscale'],
+ $editable: rte.Class.prototype.editable(), // Our parent is the root widget, we can't retrieve the editable section from it...
+ selectedColor: $(targetElement).css(eventName === "foreColor" ? 'color' : 'backgroundColor'),
+ });
+ colorpicker.on('custom_color_picked color_picked', null, ev => {
+ processAndApplyColor(ev.data.target, eventName, ev.data.color);
+ });
+ colorpicker.on('color_hover color_leave', null, ev => {
+ processAndApplyColor(ev.data.target, eventName, ev.data.color, true);
+ });
+ colorpicker.on('enter_key_color_colorpicker', null, () => {
+ $dropdown.children('.dropdown-toggle').dropdown('hide');
+ });
+ return colorpicker.replace(hookEl).then(() => {
+ if (oldColorpicker) {
+ oldColorpicker.destroy();
+ }
+ manualOpening = true;
+ $dropdown.children('.dropdown-toggle').dropdown('show');
+ manualOpening = false;
+ });
+ });
+ return false;
+ });
+ });
+};
+
+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('.note-fore-color-preview > button > .caret').css('border-bottom-color', $(oStyle.image).css('color'));
+ $container.find('.note-back-color-preview > button > .caret').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="cropImage"]').addClass('d-none');
+
+ $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('button[data-event="cropImage"]').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('.note-fore-color-preview > button > .caret').css('border-bottom-color', oStyle.color);
+ $container.find('.note-back-color-preview > button > .caret').css('border-bottom-color', oStyle['background-color']);
+ }
+};
+
+var fn_toolbar_boutton_update = eventHandler.modules.toolbar.button.update;
+eventHandler.modules.toolbar.button.update = function ($container, oStyle) {
+ fn_toolbar_boutton_update.call(this, $container, oStyle);
+
+ $container.find('button[data-event="insertUnorderedList"]').toggleClass("active", $(oStyle.ancestors).is('ul:not(.o_checklist)'));
+ $container.find('button[data-event="insertOrderedList"]').toggleClass("active", $(oStyle.ancestors).is('ol'));
+ $container.find('button[data-event="insertCheckList"]').toggleClass("active", $(oStyle.ancestors).is('ul.o_checklist'));
+};
+
+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();
+ }
+
+ const $externalHistoryButtons = $('.o_we_external_history_buttons');
+ if ($externalHistoryButtons.length) {
+ const $noteHistory = $('.note-history');
+ $noteHistory.addClass('d-none');
+ $externalHistoryButtons.find(':first-child').prop('disabled', $noteHistory.find('[data-event=undo]').prop('disabled'));
+ $externalHistoryButtons.find(':last-child').prop('disabled', $noteHistory.find('[data-event=redo]').prop('disabled'));
+ }
+ $popover.trigger('summernote_popover_update_call');
+};
+
+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 commonAncestor = linkInfo.range.commonAncestor();
+ if (commonAncestor && commonAncestor.closest) {
+ var link = commonAncestor.closest('a');
+ linkInfo.className = link && link.className;
+ }
+
+ var def = new $.Deferred();
+ topBus.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;
+};
+var originalShowImageDialog = eventHandler.modules.imageDialog.showImageDialog;
+eventHandler.modules.imageDialog.showImageDialog = function ($editable) {
+ var options = $editable.closest('.o_editable, .note-editor').data('options');
+ if (options.disableFullMediaDialog) {
+ return originalShowImageDialog.apply(this, arguments);
+ }
+ 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];
+ topBus.trigger('media_dialog_demand', {
+ $editable: $editable,
+ media: media,
+ options: {
+ onUpload: $editable.data('callbacks').onUpload,
+ noVideos: options && options.noVideos,
+ },
+ onSave: function (newMedia) {
+ if (!newMedia) {
+ return;
+ }
+ if (media) {
+ $(media).replaceWith(newMedia);
+ } else {
+ r.insertNode(newMedia);
+ }
+ },
+ });
+ return new $.Deferred().reject();
+};
+$.summernote.pluginEvents.alt = function (event, editor, layoutInfo, sorted) {
+ var $editable = layoutInfo.editable();
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ topBus.trigger('alt_dialog_demand', {
+ $editable: $editable,
+ media: $selection.data('target'),
+ });
+};
+$.summernote.pluginEvents.cropImage = function (event, editor, layoutInfo, sorted) {
+ var $editable = layoutInfo.editable();
+ var $selection = layoutInfo.handle().find('.note-control-selection');
+ topBus.trigger('crop_image_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<fonts.fontIcons.length; k++) {
+ if (_.intersection(fonts.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) {
+ 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
+ oLayoutInfo.editor().find('.note-image-dialog').on('click', '.note-image-input', function (e) {
+ e.stopPropagation(); // let browser default happen for image file input
+ });
+
+ /**
+ * 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 () {
+ // Do not show tooltip on double-click and if there is already one
+ if (!show_tooltip || $target.attr('title') !== undefined) {
+ 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, ServicesMixin, {
+ /**
+ * @constructor
+ */
+ init: function (parent) {
+ mixins.EventDispatcherMixin.init.call(this);
+ this.setParent(parent);
+
+ topBus.on('alt_dialog_demand', this, this._onAltDialogDemand);
+ topBus.on('crop_image_demand', this, this._onCropImageDemand);
+ topBus.on('link_dialog_demand', this, this._onLinkDialogDemand);
+ topBus.on('media_dialog_demand', this, this._onMediaDialogDemand);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ mixins.EventDispatcherMixin.destroy.call(this);
+
+ topBus.off('alt_dialog_demand', this, this._onAltDialogDemand);
+ topBus.off('crop_image_demand', this, this._onCropImageDemand);
+ topBus.off('link_dialog_demand', this, this._onLinkDialogDemand);
+ topBus.off('media_dialog_demand', this, this._onMediaDialogDemand);
+ },
+
+ /**
+ * Create modified image attachments.
+ *
+ * @param {jQuery} $editable
+ * @returns {Promise}
+ */
+ saveModifiedImages: function ($editable) {
+ const defs = _.map($editable, async editableEl => {
+ const {oeModel: resModel, oeId: resId} = editableEl.dataset;
+ const proms = [...editableEl.querySelectorAll('.o_modified_image_to_save')].map(async el => {
+ const isBackground = !el.matches('img');
+ el.classList.remove('o_modified_image_to_save');
+ // Modifying an image always creates a copy of the original, even if
+ // it was modified previously, as the other modified image may be used
+ // elsewhere if the snippet was duplicated or was saved as a custom one.
+ const newAttachmentSrc = await this._rpc({
+ route: `/web_editor/modify_image/${el.dataset.originalId}`,
+ params: {
+ res_model: resModel,
+ res_id: parseInt(resId),
+ data: (isBackground ? el.dataset.bgSrc : el.getAttribute('src')).split(',')[1],
+ },
+ });
+ if (isBackground) {
+ $(el).css('background-image', `url('${newAttachmentSrc}')`);
+ delete el.dataset.bgSrc;
+ } else {
+ el.setAttribute('src', newAttachmentSrc);
+ }
+ });
+ return Promise.all(proms);
+ });
+ return Promise.all(defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // 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.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 crop an image is received on the bus.
+ *
+ * @private
+ * @param {Object} data
+ */
+ _onCropImageDemand: function (data) {
+ if (data.__alreadyDone) {
+ return;
+ }
+ data.__alreadyDone = true;
+ new weWidgets.ImageCropWidget(this, data.media)
+ .appendTo(data.$editable.parent());
+ },
+ /**
+ * 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;
+
+ const model = data.$editable.data('oe-model');
+ const field = data.$editable.data('oe-field');
+ const type = data.$editable.data('oe-type');
+ var mediaDialog = new weWidgets.MediaDialog(this,
+ _.extend({
+ res_model: model,
+ res_id: data.$editable.data('oe-id'),
+ domain: data.$editable.data('oe-media-domain'),
+ useMediaLibrary: field && (model === 'ir.ui.view' && field === 'arch' || type === 'html'),
+ }, data.options),
+ data.media
+ );
+ if (data.onSave) {
+ mediaDialog.on('save', this, data.onSave);
+ }
+ if (data.onCancel) {
+ mediaDialog.on('cancel', this, data.onCancel);
+ }
+ mediaDialog.open();
+ },
+});
+return SummernoteManager;
+});
diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js
new file mode 100644
index 00000000..dc232e1d
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/snippets.editor.js
@@ -0,0 +1,2776 @@
+odoo.define('web_editor.snippet.editor', function (require) {
+'use strict';
+
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var Widget = require('web.Widget');
+var options = require('web_editor.snippets.options');
+var Wysiwyg = require('web_editor.wysiwyg');
+const {ColorPaletteWidget} = require('web_editor.ColorPalette');
+const SmoothScrollOnDrag = require('web/static/src/js/core/smooth_scroll_on_drag.js');
+const {getCSSVariableValue} = require('web_editor.utils');
+
+var _t = core._t;
+
+var globalSelector = {
+ closest: () => $(),
+ all: () => $(),
+ is: () => false,
+};
+
+/**
+ * Management of the overlay and option list for a snippet.
+ */
+var SnippetEditor = Widget.extend({
+ template: 'web_editor.snippet_overlay',
+ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'],
+ events: {
+ 'click .oe_snippet_remove': '_onRemoveClick',
+ 'wheel': '_onMouseWheel',
+ },
+ custom_events: {
+ 'option_update': '_onOptionUpdate',
+ 'user_value_widget_request': '_onUserValueWidgetRequest',
+ 'snippet_option_update': '_onSnippetOptionUpdate', // TODO remove me in master
+ 'snippet_option_visibility_update': '_onSnippetOptionVisibilityUpdate',
+ },
+ layoutElementsSelector: [
+ '.o_we_shape',
+ '.o_we_bg_filter',
+ ].join(','),
+
+ /**
+ * @constructor
+ * @param {Widget} parent
+ * @param {Element} target
+ * @param {Object} templateOptions
+ * @param {jQuery} $editable
+ * @param {Object} options
+ */
+ 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;
+ this.isTargetParentEditable = false;
+ this.isTargetMovable = false;
+ this.$scrollingElement = $().getScrollingElement();
+
+ this.__isStarted = new Promise(resolve => {
+ this.__isStartedResolveFunc = resolve;
+ });
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var defs = [this._super.apply(this, arguments)];
+
+ // Initialize the associated options (see snippets.options.js)
+ defs.push(this._initializeOptions());
+ var $customize = this._customize$Elements[this._customize$Elements.length - 1];
+
+ this.isTargetParentEditable = this.$target.parent().is(':o_editable');
+ this.isTargetMovable = this.isTargetParentEditable && this.isTargetMovable;
+ this.isTargetRemovable = this.isTargetParentEditable && !this.$target.parent().is('[data-oe-type="image"]');
+
+ // Initialize move/clone/remove buttons
+ if (this.isTargetMovable) {
+ this.dropped = false;
+ const smoothScrollOptions = this.options.getScrollOptions({
+ jQueryDraggableOptions: {
+ cursorAt: {
+ left: 10,
+ top: 10
+ },
+ handle: '.o_move_handle',
+ helper: () => {
+ var $clone = this.$el.clone().css({width: '24px', height: '24px', border: 0});
+ $clone.appendTo(this.$body).removeClass('d-none');
+ return $clone;
+ },
+ start: this._onDragAndDropStart.bind(this),
+ stop: (...args) => {
+ // Delay our stop handler so that some summernote handlers
+ // which occur on mouseup (and are themself delayed) are
+ // executed first (this prevents the library to crash
+ // because our stop handler may change the DOM).
+ setTimeout(() => {
+ this._onDragAndDropStop(...args);
+ }, 0);
+ },
+ },
+ });
+ this.draggableComponent = new SmoothScrollOnDrag(this, this.$el, $().getScrollingElement(), smoothScrollOptions);
+ } else {
+ this.$('.o_overlay_move_options').addClass('d-none');
+ $customize.find('.oe_snippet_clone').addClass('d-none');
+ }
+
+ if (!this.isTargetRemovable) {
+ this.$el.add($customize).find('.oe_snippet_remove').addClass('d-none');
+ }
+
+ var _animationsCount = 0;
+ var postAnimationCover = _.throttle(() => this.cover(), 100);
+ this.$target.on('transitionstart.snippet_editor, animationstart.snippet_editor', () => {
+ // We cannot rely on the fact each transition/animation start will
+ // trigger a transition/animation end as the element may be removed
+ // from the DOM before or it could simply be an infinite animation.
+ //
+ // By simplicity, for each start, we add a delayed operation that
+ // will decrease the animation counter after a fixed duration and
+ // do the post animation cover if none is registered anymore.
+ _animationsCount++;
+ setTimeout(() => {
+ if (!--_animationsCount) {
+ postAnimationCover();
+ }
+ }, 500); // This delay have to be huge enough to take care of long
+ // animations which will not trigger an animation end event
+ // but if it is too small for some, this is the job of the
+ // animation creator to manually ask for a re-cover
+ });
+ // On top of what is explained above, do the post animation cover for
+ // each detected transition/animation end so that the user does not see
+ // a flickering when not needed.
+ this.$target.on('transitionend.snippet_editor, animationend.snippet_editor', postAnimationCover);
+
+ return Promise.all(defs).then(() => {
+ this.__isStartedResolveFunc(this);
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ // Before actually destroying a snippet editor, notify the parent
+ // about it so that it can update its list of alived snippet editors.
+ this.trigger_up('snippet_editor_destroyed');
+
+ this._super(...arguments);
+ this.$target.removeData('snippet-editor');
+ this.$target.off('.snippet_editor');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Checks whether the snippet options are shown or not.
+ *
+ * @returns {boolean}
+ */
+ areOptionsShown: function () {
+ const lastIndex = this._customize$Elements.length - 1;
+ return !!this._customize$Elements[lastIndex].parent().length;
+ },
+ /**
+ * Notifies all the associated snippet options that the snippet has just
+ * been dropped in the page.
+ */
+ buildSnippet: async function () {
+ for (var i in this.styles) {
+ this.styles[i].onBuilt();
+ }
+ await this.toggleTargetVisibility(true);
+ },
+ /**
+ * Notifies all the associated snippet options that the template which
+ * contains the snippet is about to be saved.
+ */
+ cleanForSave: async function () {
+ if (this.isDestroyed()) {
+ return;
+ }
+ await this.toggleTargetVisibility(!this.$target.hasClass('o_snippet_invisible'));
+ const proms = _.map(this.styles, option => {
+ return option.cleanForSave();
+ });
+ await Promise.all(proms);
+ },
+ /**
+ * Closes all widgets of all options.
+ */
+ closeWidgets: function () {
+ if (!this.styles || !this.areOptionsShown()) {
+ return;
+ }
+ Object.keys(this.styles).forEach(key => {
+ this.styles[key].closeWidgets();
+ });
+ },
+ /**
+ * Makes the editor overlay cover the associated snippet.
+ */
+ cover: function () {
+ if (!this.isShown() || !this.$target.length) {
+ return;
+ }
+
+ const $modal = this.$target.find('.modal');
+ const $target = $modal.length ? $modal : this.$target;
+ const targetEl = $target[0];
+
+ // Check first if the target is still visible, otherwise we have to
+ // hide it. When covering all element after scroll for instance it may
+ // have been hidden (part of an affixed header for example) or it may
+ // be outside of the viewport (the whole header during an effect for
+ // example).
+ const rect = targetEl.getBoundingClientRect();
+ const vpWidth = window.innerWidth || document.documentElement.clientWidth;
+ const vpHeight = window.innerHeight || document.documentElement.clientHeight;
+ const isInViewport = (
+ rect.bottom > -0.1 &&
+ rect.right > -0.1 &&
+ (vpHeight - rect.top) > -0.1 &&
+ (vpWidth - rect.left) > -0.1
+ );
+ const hasSize = ( // :visible not enough for images
+ Math.abs(rect.bottom - rect.top) > 0.01 &&
+ Math.abs(rect.right - rect.left) > 0.01
+ );
+ if (!isInViewport || !hasSize || !this.$target.is(`:visible`)) {
+ this.toggleOverlayVisibility(false);
+ return;
+ }
+
+ // Now cover the element
+ const offset = $target.offset();
+ var manipulatorOffset = this.$el.parent().offset();
+ offset.top -= manipulatorOffset.top;
+ offset.left -= manipulatorOffset.left;
+ this.$el.css({
+ width: $target.outerWidth(),
+ left: offset.left,
+ top: offset.top,
+ });
+ this.$('.o_handles').css('height', $target.outerHeight());
+
+ const editableOffsetTop = this.$editable.offset().top - manipulatorOffset.top;
+ this.$el.toggleClass('o_top_cover', offset.top - editableOffsetTop < 25);
+ },
+ /**
+ * DOMElements have a default name which appears in the overlay when they
+ * are being edited. This method retrieves this name; it can be defined
+ * directly in the DOM thanks to the `data-name` attribute.
+ */
+ getName: function () {
+ if (this.$target.data('name') !== undefined) {
+ return this.$target.data('name');
+ }
+ if (this.$target.is('img')) {
+ return _t("Image");
+ }
+ if (this.$target.parent('.row').length) {
+ return _t("Column");
+ }
+ return _t("Block");
+ },
+ /**
+ * @return {boolean}
+ */
+ isShown: function () {
+ return this.$el && this.$el.parent().length && this.$el.hasClass('oe_active');
+ },
+ /**
+ * @returns {boolean}
+ */
+ isSticky: function () {
+ return this.$el && this.$el.hasClass('o_we_overlay_sticky');
+ },
+ /**
+ * @returns {boolean}
+ */
+ isTargetVisible: function () {
+ return (this.$target[0].dataset.invisible !== '1');
+ },
+ /**
+ * Removes the associated snippet from the DOM and destroys the associated
+ * editor (itself).
+ *
+ * @returns {Promise}
+ */
+ removeSnippet: async function () {
+ this.toggleOverlay(false);
+ await this.toggleOptions(false);
+ // If it is an invisible element, we must close it before deleting it (e.g. modal)
+ await this.toggleTargetVisibility(!this.$target.hasClass('o_snippet_invisible'));
+
+ await new Promise(resolve => {
+ this.trigger_up('call_for_each_child_snippet', {
+ $snippet: this.$target,
+ callback: function (editor, $snippet) {
+ for (var i in editor.styles) {
+ editor.styles[i].onRemove();
+ }
+ resolve();
+ },
+ });
+ });
+
+ this.trigger_up('go_to_parent', {$snippet: this.$target});
+ var $parent = this.$target.parent();
+ this.$target.find('*').addBack().tooltip('dispose');
+ this.$target.remove();
+ this.$el.remove();
+
+ var node = $parent[0];
+ if (node && node.firstChild) {
+ if (!node.firstChild.tagName && node.firstChild.textContent === ' ') {
+ node.removeChild(node.firstChild);
+ }
+ }
+
+ if ($parent.closest(':data("snippet-editor")').length) {
+ const isEmptyAndRemovable = ($el, editor) => {
+ editor = editor || $el.data('snippet-editor');
+ const isEmpty = $el.text().trim() === ''
+ && $el.children().toArray().every(el => {
+ // Consider layout-only elements (like bg-shapes) as empty
+ return el.matches(this.layoutElementsSelector);
+ });
+ return isEmpty && !$el.hasClass('oe_structure')
+ && (!editor || editor.isTargetParentEditable);
+ };
+
+ var editor = $parent.data('snippet-editor');
+ while (!editor) {
+ var $nextParent = $parent.parent();
+ if (isEmptyAndRemovable($parent)) {
+ $parent.remove();
+ }
+ $parent = $nextParent;
+ editor = $parent.data('snippet-editor');
+ }
+ if (isEmptyAndRemovable($parent, editor)) {
+ // TODO maybe this should be part of the actual Promise being
+ // returned by the function ?
+ setTimeout(() => editor.removeSnippet());
+ }
+ }
+
+ // clean editor if they are image or table in deleted content
+ 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');
+ // TODO Page content changed, some elements may need to be adapted
+ // according to it. While waiting for a better way to handle that this
+ // window trigger will handle most cases.
+ $(window).trigger('resize');
+ },
+ /**
+ * Displays/Hides the editor overlay.
+ *
+ * @param {boolean} show
+ * @param {boolean} [previewMode=false]
+ */
+ toggleOverlay: function (show, previewMode) {
+ if (!this.$el) {
+ return;
+ }
+
+ if (previewMode) {
+ // In preview mode, the sticky classes are left untouched, we only
+ // add/remove the preview class when toggling/untoggling
+ this.$el.toggleClass('o_we_overlay_preview', show);
+ } else {
+ // In non preview mode, the preview class is always removed, and the
+ // sticky class is added/removed when toggling/untoggling
+ this.$el.removeClass('o_we_overlay_preview');
+ this.$el.toggleClass('o_we_overlay_sticky', show);
+ }
+
+ // Show/hide overlay in preview mode or not
+ this.$el.toggleClass('oe_active', show);
+ this.cover();
+ },
+ /**
+ * Displays/Hides the editor (+ parent) options and call onFocus/onBlur if
+ * necessary.
+ *
+ * @param {boolean} show
+ * @returns {Promise}
+ */
+ async toggleOptions(show) {
+ if (!this.$el) {
+ return;
+ }
+
+ if (this.areOptionsShown() === show) {
+ return;
+ }
+ // TODO should update the panel after the items have been updated
+ this.trigger_up('update_customize_elements', {
+ customize$Elements: show ? this._customize$Elements : [],
+ });
+ // All onFocus before all ui updates as the onFocus of an option might
+ // affect another option (like updating the $target)
+ const editorUIsToUpdate = [];
+ const focusOrBlur = show
+ ? (editor, options) => {
+ for (const opt of options) {
+ opt.onFocus();
+ }
+ editorUIsToUpdate.push(editor);
+ }
+ : (editor, options) => {
+ for (const opt of options) {
+ opt.onBlur();
+ }
+ };
+ for (const $el of this._customize$Elements) {
+ const editor = $el.data('editor');
+ const styles = _.chain(editor.styles)
+ .values()
+ .sortBy('__order')
+ .value();
+ // TODO ideally: allow async parts in onFocus/onBlur
+ focusOrBlur(editor, styles);
+ }
+ await Promise.all(editorUIsToUpdate.map(editor => editor.updateOptionsUI()));
+ await Promise.all(editorUIsToUpdate.map(editor => editor.updateOptionsUIVisibility()));
+ },
+ /**
+ * @param {boolean} [show]
+ * @returns {Promise<boolean>}
+ */
+ toggleTargetVisibility: async function (show) {
+ show = this._toggleVisibilityStatus(show);
+ var styles = _.values(this.styles);
+ const proms = _.sortBy(styles, '__order').map(style => {
+ return show ? style.onTargetShow() : style.onTargetHide();
+ });
+ await Promise.all(proms);
+ return show;
+ },
+ /**
+ * @param {boolean} [show=false]
+ */
+ toggleOverlayVisibility: function (show) {
+ if (this.$el && !this.scrollingTimeout) {
+ this.$el.toggleClass('o_overlay_hidden', !show && this.isShown());
+ }
+ },
+ /**
+ * Updates the UI of all the options according to the status of their
+ * associated editable DOM. This does not take care of options *visibility*.
+ * For that @see updateOptionsUIVisibility, which should called when the UI
+ * is up-to-date thanks to the function here, as the visibility depends on
+ * the UI's status.
+ *
+ * @returns {Promise}
+ */
+ async updateOptionsUI() {
+ const proms = Object.values(this.styles).map(opt => {
+ return opt.updateUI({noVisibility: true});
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Updates the visibility of the UI of all the options according to the
+ * status of their associated dependencies and related editable DOM status.
+ *
+ * @returns {Promise}
+ */
+ async updateOptionsUIVisibility() {
+ const proms = Object.values(this.styles).map(opt => {
+ return opt.updateUIVisibility();
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * Clones the current snippet.
+ *
+ * @private
+ * @param {boolean} recordUndo
+ */
+ clone: async function (recordUndo) {
+ this.trigger_up('snippet_will_be_cloned', {$target: this.$target});
+
+ var $clone = this.$target.clone(false);
+
+ if (recordUndo) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ }
+
+ this.$target.after($clone);
+ await new Promise(resolve => {
+ this.trigger_up('call_for_each_child_snippet', {
+ $snippet: $clone,
+ callback: function (editor, $snippet) {
+ for (var i in editor.styles) {
+ editor.styles[i].onClone({
+ isCurrent: ($snippet.is($clone)),
+ });
+ }
+ resolve();
+ },
+ });
+ });
+ this.trigger_up('snippet_cloned', {$target: $clone, $origin: this.$target});
+
+ $clone.trigger('content_changed');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Instantiates the snippet's options.
+ *
+ * @private
+ */
+ _initializeOptions: function () {
+ this._customize$Elements = [];
+ this.styles = {};
+ this.selectorSiblings = [];
+ this.selectorChildren = [];
+
+ var $element = this.$target.parent();
+ while ($element.length) {
+ var parentEditor = $element.data('snippet-editor');
+ if (parentEditor) {
+ this._customize$Elements = this._customize$Elements
+ .concat(parentEditor._customize$Elements);
+ break;
+ }
+ $element = $element.parent();
+ }
+
+ var $optionsSection = $(core.qweb.render('web_editor.customize_block_options_section', {
+ name: this.getName(),
+ })).data('editor', this);
+ const $optionsSectionBtnGroup = $optionsSection.find('we-top-button-group');
+ $optionsSectionBtnGroup.contents().each((i, node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ node.parentNode.removeChild(node);
+ }
+ });
+ $optionsSection.on('mouseenter', this._onOptionsSectionMouseEnter.bind(this));
+ $optionsSection.on('mouseleave', this._onOptionsSectionMouseLeave.bind(this));
+ $optionsSection.on('click', 'we-title > span', this._onOptionsSectionClick.bind(this));
+ $optionsSection.on('click', '.oe_snippet_clone', this._onCloneClick.bind(this));
+ $optionsSection.on('click', '.oe_snippet_remove', this._onRemoveClick.bind(this));
+ this._customize$Elements.push($optionsSection);
+
+ // TODO get rid of this when possible (made as a fix to support old
+ // theme options)
+ this.$el.data('$optionsSection', $optionsSection);
+
+ var i = 0;
+ var defs = _.map(this.templateOptions, val => {
+ if (!val.selector.is(this.$target)) {
+ return;
+ }
+ if (val['drop-near']) {
+ this.selectorSiblings.push(val['drop-near']);
+ }
+ if (val['drop-in']) {
+ this.selectorChildren.push(val['drop-in']);
+ }
+
+ var optionName = val.option;
+ var option = new (options.registry[optionName] || options.Class)(
+ this,
+ val.$el.children(),
+ val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target,
+ this.$el,
+ _.extend({
+ optionName: optionName,
+ snippetName: this.getName(),
+ }, val.data),
+ this.options
+ );
+ var key = optionName || _.uniqueId('option');
+ if (this.styles[key]) {
+ // If two snippet options use the same option name (and so use
+ // the same JS option), store the subsequent ones with a unique
+ // ID (TODO improve)
+ key = _.uniqueId(key);
+ }
+ this.styles[key] = option;
+ option.__order = i++;
+
+ if (option.forceNoDeleteButton) {
+ this.$el.add($optionsSection).find('.oe_snippet_remove').addClass('d-none');
+ }
+
+ return option.appendTo(document.createDocumentFragment());
+ });
+
+ this.isTargetMovable = (this.selectorSiblings.length > 0 || this.selectorChildren.length > 0);
+
+ this.$el.find('[data-toggle="dropdown"]').dropdown();
+
+ return Promise.all(defs).then(() => {
+ const options = _.sortBy(this.styles, '__order');
+ options.forEach(option => {
+ if (option.isTopOption) {
+ $optionsSectionBtnGroup.prepend(option.$el);
+ } else {
+ $optionsSection.append(option.$el);
+ }
+ });
+ $optionsSection.toggleClass('d-none', options.length === 0);
+ });
+ },
+ /**
+ * @private
+ * @param {boolean} [show]
+ */
+ _toggleVisibilityStatus: function (show) {
+ if (show === undefined) {
+ show = !this.isTargetVisible();
+ }
+ if (show) {
+ delete this.$target[0].dataset.invisible;
+ } else {
+ this.$target[0].dataset.invisible = '1';
+ }
+ return show;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the 'clone' button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onCloneClick: function (ev) {
+ ev.preventDefault();
+ this.clone(true);
+ },
+ /**
+ * Called when the snippet is starting to be dragged thanks to the 'move'
+ * button.
+ *
+ * @private
+ */
+ _onDragAndDropStart: function () {
+ var self = this;
+ this.dropped = false;
+ self.size = {
+ width: self.$target.width(),
+ height: self.$target.height()
+ };
+ self.$target.after('<div class="oe_drop_clone" style="display: none;"/>');
+ self.$target.detach();
+ self.$el.addClass('d-none');
+
+ var $selectorSiblings;
+ for (var i = 0; i < self.selectorSiblings.length; i++) {
+ if (!$selectorSiblings) {
+ $selectorSiblings = self.selectorSiblings[i].all();
+ } else {
+ $selectorSiblings = $selectorSiblings.add(self.selectorSiblings[i].all());
+ }
+ }
+ var $selectorChildren;
+ for (i = 0; i < self.selectorChildren.length; i++) {
+ if (!$selectorChildren) {
+ $selectorChildren = self.selectorChildren[i].all();
+ } else {
+ $selectorChildren = $selectorChildren.add(self.selectorChildren[i].all());
+ }
+ }
+
+ this.trigger_up('go_to_parent', {$snippet: this.$target});
+ this.trigger_up('activate_insertion_zones', {
+ $selectorSiblings: $selectorSiblings,
+ $selectorChildren: $selectorChildren,
+ });
+
+ this.$body.addClass('move-important');
+
+ this.$editable.find('.oe_drop_zone').droppable({
+ over: function () {
+ if (self.dropped) {
+ self.$target.detach();
+ $('.oe_drop_zone').removeClass('invisible');
+ }
+ self.dropped = true;
+ $(this).first().after(self.$target).addClass('invisible');
+ },
+ out: function () {
+ var prev = self.$target.prev();
+ if (this === prev[0]) {
+ self.dropped = false;
+ self.$target.detach();
+ $(this).removeClass('invisible');
+ }
+ },
+ });
+
+ // If a modal is open, the scroll target must be that modal
+ const $openModal = self.$editable.find('.modal:visible');
+ self.draggableComponent.$scrollTarget = $openModal.length ? $openModal : self.$scrollingElement;
+
+ // Trigger a scroll on the draggable element so that jQuery updates
+ // the position of the drop zones.
+ self.draggableComponent.$scrollTarget.on('scroll.scrolling_element', function () {
+ self.$el.trigger('scroll');
+ });
+ },
+ /**
+ * Called when the snippet is dropped after being dragged thanks to the
+ * 'move' button.
+ *
+ * @private
+ * @param {Event} ev
+ * @param {Object} ui
+ */
+ _onDragAndDropStop: function (ev, ui) {
+ // TODO lot of this is duplicated code of the d&d feature of snippets
+ if (!this.dropped) {
+ var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone', {container: document.body}).first();
+ if ($el.length) {
+ $el.after(this.$target);
+ this.dropped = true;
+ }
+ }
+
+ 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 = 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');
+ this.$body.removeClass('move-important');
+ $clone.remove();
+
+ if (this.dropped) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+
+ if (prev) {
+ this.$target.insertAfter(prev);
+ } else if (next) {
+ this.$target.insertBefore(next);
+ } else {
+ $parent.prepend(this.$target);
+ }
+
+ for (var i in this.styles) {
+ this.styles[i].onMove();
+ }
+
+ this.$target.trigger('content_changed');
+ $from.trigger('content_changed');
+ }
+
+ this.trigger_up('drag_and_drop_stop', {
+ $snippet: this.$target,
+ });
+ this.draggableComponent.$scrollTarget.off('scroll.scrolling_element');
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionMouseEnter: function (ev) {
+ if (!this.$target.is(':visible')) {
+ return;
+ }
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: true,
+ });
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionMouseLeave: function (ev) {
+ this.trigger_up('activate_snippet', {
+ $snippet: false,
+ previewMode: true,
+ });
+ },
+ /**
+ * @private
+ */
+ _onOptionsSectionClick: function (ev) {
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: false,
+ });
+ },
+ /**
+ * Called when a child editor/option asks for another option to perform a
+ * specific action/react to a specific event.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onOptionUpdate: function (ev) {
+ var self = this;
+
+ // If multiple option names are given, we suppose it should not be
+ // propagated to parent editor
+ if (ev.data.optionNames) {
+ ev.stopPropagation();
+ _.each(ev.data.optionNames, function (name) {
+ notifyForEachMatchedOption(name);
+ });
+ }
+ // If one option name is given, we suppose it should be handle by the
+ // first parent editor which can do it
+ if (ev.data.optionName) {
+ if (notifyForEachMatchedOption(ev.data.optionName)) {
+ ev.stopPropagation();
+ }
+ }
+
+ function notifyForEachMatchedOption(name) {
+ var regex = new RegExp('^' + name + '\\d+$');
+ var hasOption = false;
+ for (var key in self.styles) {
+ if (key === name || regex.test(key)) {
+ self.styles[key].notify(ev.data.name, ev.data.data);
+ hasOption = true;
+ }
+ }
+ return hasOption;
+ }
+ },
+ /**
+ * Called when the 'remove' button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onRemoveClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ this.removeSnippet();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionUpdate: async function (ev) {
+ // TODO remove me in master
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionVisibilityUpdate: function (ev) {
+ ev.data.show = this._toggleVisibilityStatus(ev.data.show);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onUserValueWidgetRequest: function (ev) {
+ ev.stopPropagation();
+ for (const key of Object.keys(this.styles)) {
+ const widget = this.styles[key].findWidget(ev.data.name);
+ if (widget) {
+ ev.data.onSuccess(widget);
+ return;
+ }
+ }
+ },
+ /**
+ * Called when the 'mouse wheel' is used when hovering over the overlay.
+ * Disable the pointer events to prevent page scrolling from stopping.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseWheel: function (ev) {
+ ev.stopPropagation();
+ this.$el.css('pointer-events', 'none');
+ clearTimeout(this.wheelTimeout);
+ this.wheelTimeout = setTimeout(() => {
+ this.$el.css('pointer-events', '');
+ }, 250);
+ },
+});
+
+/**
+ * Management of drag&drop menu and snippet related behaviors in the page.
+ */
+var SnippetsMenu = Widget.extend({
+ id: 'oe_snippets',
+ cacheSnippetTemplate: {},
+ events: {
+ 'click .oe_snippet': '_onSnippetClick',
+ 'click .o_install_btn': '_onInstallBtnClick',
+ 'click .o_we_add_snippet_btn': '_onBlocksTabClick',
+ 'click .o_we_invisible_entry': '_onInvisibleEntryClick',
+ 'click #snippet_custom .o_delete_btn': '_onDeleteBtnClick',
+ 'mousedown': '_onMouseDown',
+ 'input .o_snippet_search_filter_input': '_onSnippetSearchInput',
+ 'click .o_snippet_search_filter_reset': '_onSnippetSearchResetClick',
+ 'summernote_popover_update_call .o_we_snippet_text_tools': '_onSummernoteToolsUpdate',
+ },
+ custom_events: {
+ 'activate_insertion_zones': '_onActivateInsertionZones',
+ 'activate_snippet': '_onActivateSnippet',
+ 'call_for_each_child_snippet': '_onCallForEachChildSnippet',
+ 'clone_snippet': '_onCloneSnippet',
+ 'cover_update': '_onOverlaysCoverUpdate',
+ 'deactivate_snippet': '_onDeactivateSnippet',
+ 'drag_and_drop_stop': '_onDragAndDropStop',
+ 'get_snippet_versions': '_onGetSnippetVersions',
+ 'go_to_parent': '_onGoToParent',
+ 'remove_snippet': '_onRemoveSnippet',
+ 'snippet_edition_request': '_onSnippetEditionRequest',
+ 'snippet_editor_destroyed': '_onSnippetEditorDestroyed',
+ 'snippet_removed': '_onSnippetRemoved',
+ 'snippet_cloned': '_onSnippetCloned',
+ 'snippet_option_update': '_onSnippetOptionUpdate',
+ 'snippet_option_visibility_update': '_onSnippetOptionVisibilityUpdate',
+ 'snippet_thumbnail_url_request': '_onSnippetThumbnailURLRequest',
+ 'reload_snippet_dropzones': '_disableUndroppableSnippets',
+ 'request_save': '_onSaveRequest',
+ 'update_customize_elements': '_onUpdateCustomizeElements',
+ 'hide_overlay': '_onHideOverlay',
+ 'block_preview_overlays': '_onBlockPreviewOverlays',
+ 'unblock_preview_overlays': '_onUnblockPreviewOverlays',
+ 'user_value_widget_opening': '_onUserValueWidgetOpening',
+ 'user_value_widget_closing': '_onUserValueWidgetClosing',
+ 'reload_snippet_template': '_onReloadSnippetTemplate',
+ },
+ // enum of the SnippetsMenu's tabs.
+ tabs: {
+ BLOCKS: 'blocks',
+ OPTIONS: 'options',
+ },
+
+ /**
+ * @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, options) {
+ this._super.apply(this, arguments);
+ options = options || {};
+ this.trigger_up('getRecordInfo', {
+ recordInfo: options,
+ callback: function (recordInfo) {
+ _.defaults(options, recordInfo);
+ },
+ });
+
+ this.options = options;
+ if (!this.options.snippets) {
+ this.options.snippets = 'web_editor.snippets';
+ }
+ this.snippetEditors = [];
+ this._enabledEditorHierarchy = [];
+
+ this._mutex = new concurrency.Mutex();
+
+ this.setSelectorEditableArea(options.$el, options.selectorEditableArea);
+
+ this._notActivableElementsSelector = [
+ '#web_editor-top-edit',
+ '.o_we_website_top_actions',
+ '#oe_snippets',
+ '#oe_manipulators',
+ '.o_technical_modal',
+ '.oe_drop_zone',
+ '.o_notification_manager',
+ '.o_we_no_overlay',
+ '.ui-autocomplete',
+ '.modal .close',
+ '.o_we_crop_widget',
+ ].join(', ');
+
+ this.loadingTimers = {};
+ this.loadingElements = {};
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ // Preload colorpalette dependencies without waiting for them. The
+ // widget have huge chances of being used by the user (clicking on any
+ // text will load it). The colorpalette itself will do the actual
+ // waiting of the loading completion.
+ ColorPaletteWidget.loadDependencies(this);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async start() {
+ var defs = [this._super.apply(this, arguments)];
+ this.ownerDocument = this.$el[0].ownerDocument;
+ this.$document = $(this.ownerDocument);
+ this.window = this.ownerDocument.defaultView;
+ this.$window = $(this.window);
+
+ this.customizePanel = document.createElement('div');
+ this.customizePanel.classList.add('o_we_customize_panel', 'd-none');
+
+ this.textEditorPanelEl = document.createElement('div');
+ this.textEditorPanelEl.classList.add('o_we_snippet_text_tools', 'd-none');
+
+ this.invisibleDOMPanelEl = document.createElement('div');
+ this.invisibleDOMPanelEl.classList.add('o_we_invisible_el_panel');
+ this.invisibleDOMPanelEl.appendChild(
+ $('<div/>', {
+ text: _t('Invisible Elements'),
+ class: 'o_panel_header',
+ })[0]
+ );
+
+ this.options.getScrollOptions = this._getScrollOptions.bind(this);
+
+ // Fetch snippet templates and compute it
+ defs.push((async () => {
+ await this._loadSnippetsTemplates();
+ await this._updateInvisibleDOM();
+ })());
+
+ // Prepare snippets editor environment
+ this.$snippetEditorArea = $('<div/>', {
+ id: 'oe_manipulators',
+ }).insertAfter(this.$el);
+
+ // Active snippet editor on click in the page
+ var lastElement;
+ const onClick = ev => {
+ var srcElement = ev.target || (ev.originalEvent && (ev.originalEvent.target || ev.originalEvent.originalTarget)) || ev.srcElement;
+ if (!srcElement || lastElement === srcElement) {
+ return;
+ }
+ lastElement = srcElement;
+ _.defer(function () {
+ lastElement = false;
+ });
+
+ var $target = $(srcElement);
+ if (!$target.closest('we-button, we-toggler, we-select, .o_we_color_preview').length) {
+ this._closeWidgets();
+ }
+ if (!$target.closest('body > *').length) {
+ return;
+ }
+ if ($target.closest(this._notActivableElementsSelector).length) {
+ return;
+ }
+ const $oeStructure = $target.closest('.oe_structure');
+ if ($oeStructure.length && !$oeStructure.children().length && this.$snippets) {
+ // If empty oe_structure, encourage using snippets in there by
+ // making them "wizz" in the panel.
+ this.$snippets.odooBounce();
+ return;
+ }
+ this._activateSnippet($target);
+ };
+
+ this.$document.on('click.snippets_menu', '*', onClick);
+ // Needed as bootstrap stop the propagation of click events for dropdowns
+ this.$document.on('mouseup.snippets_menu', '.dropdown-toggle', onClick);
+
+ core.bus.on('deactivate_snippet', this, this._onDeactivateSnippet);
+
+ // Adapt overlay covering when the window is resized / content changes
+ var debouncedCoverUpdate = _.throttle(() => {
+ this.updateCurrentSnippetEditorOverlay();
+ }, 50);
+ 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
+ this.$document.on('keydown.snippets_menu', () => {
+ this.__overlayKeyWasDown = true;
+ this.snippetEditors.forEach(editor => {
+ editor.toggleOverlayVisibility(false);
+ });
+ });
+ this.$document.on('mousemove.snippets_menu, mousedown.snippets_menu', _.throttle(() => {
+ if (!this.__overlayKeyWasDown) {
+ return;
+ }
+ this.__overlayKeyWasDown = false;
+ this.snippetEditors.forEach(editor => {
+ editor.toggleOverlayVisibility(true);
+ editor.cover();
+ });
+ }, 250));
+
+ // Hide the active overlay when scrolling.
+ // Show it again and recompute all the overlays after the scroll.
+ this.$scrollingElement = $().getScrollingElement();
+ this._onScrollingElementScroll = _.throttle(() => {
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlayVisibility(false);
+ }
+ clearTimeout(this.scrollingTimeout);
+ this.scrollingTimeout = setTimeout(() => {
+ this._scrollingTimeout = null;
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlayVisibility(true);
+ editor.cover();
+ }
+ }, 250);
+ }, 50);
+ // We use addEventListener instead of jQuery because we need 'capture'.
+ // Setting capture to true allows to take advantage of event bubbling
+ // for events that otherwise don’t support it. (e.g. useful when
+ // scrolling a modal)
+ this.$scrollingElement[0].addEventListener('scroll', this._onScrollingElementScroll, {capture: true});
+
+ // Auto-selects text elements with a specific class and remove this
+ // on text changes
+ this.$document.on('click.snippets_menu', '.o_default_snippet_text', function (ev) {
+ $(ev.target).closest('.o_default_snippet_text').removeClass('o_default_snippet_text');
+ $(ev.target).selectContent();
+ $(ev.target).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');
+ });
+
+ const $autoFocusEls = $('.o_we_snippet_autofocus');
+ this._activateSnippet($autoFocusEls.length ? $autoFocusEls.first() : false);
+
+ // Add tooltips on we-title elements whose text overflows
+ this.$el.tooltip({
+ selector: 'we-title',
+ placement: 'bottom',
+ delay: 100,
+ title: function () {
+ const el = this;
+ // On Firefox, el.scrollWidth is equal to el.clientWidth when
+ // overflow: hidden, so we need to update the style before to
+ // get the right values.
+ el.style.setProperty('overflow', 'scroll', 'important');
+ const tipContent = el.scrollWidth > el.clientWidth ? el.innerHTML : '';
+ el.style.removeProperty('overflow');
+ return tipContent;
+ },
+ });
+
+ return Promise.all(defs).then(() => {
+ this.$('[data-title]').tooltip({
+ delay: 100,
+ title: function () {
+ return this.classList.contains('active') ? false : this.dataset.title;
+ },
+ });
+
+ // Trigger a resize event once entering edit mode as the snippets
+ // menu will take part of the screen width (delayed because of
+ // animation). (TODO wait for real animation end)
+ setTimeout(() => {
+ this.$window.trigger('resize');
+
+ // Hacky way to prevent to switch to text tools on editor
+ // start. Only allow switching after some delay. Switching to
+ // tools is only useful for out-of-snippet texts anyway, so
+ // snippet texts can still be enabled immediately.
+ this._mutex.exec(() => this._textToolsSwitchingEnabled = true);
+ }, 1000);
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ if (this.$window) {
+ this.$snippetEditorArea.remove();
+ this.$window.off('.snippets_menu');
+ this.$document.off('.snippets_menu');
+ this.$scrollingElement[0].removeEventListener('scroll', this._onScrollingElementScroll, {capture: true});
+ }
+ core.bus.off('deactivate_snippet', this, this._onDeactivateSnippet);
+ delete this.cacheSnippetTemplate[this.options.snippets];
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Prepares the page so that it may be saved:
+ * - Asks the snippet editors to clean their associated snippet
+ * - Remove the 'contentEditable' attributes
+ */
+ cleanForSave: async function () {
+ await this._activateSnippet(false);
+ this.trigger_up('ready_to_clean_for_save');
+ await this._destroyEditors();
+
+ this.getEditableArea().find('[contentEditable]')
+ .removeAttr('contentEditable')
+ .removeProp('contentEditable');
+
+ this.getEditableArea().find('.o_we_selected_image')
+ .removeClass('o_we_selected_image');
+ },
+ /**
+ * Load snippets.
+ * @param {boolean} invalidateCache
+ */
+ loadSnippets: function (invalidateCache) {
+ if (!invalidateCache && 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_public_asset',
+ args: [this.options.snippets, {}],
+ kwargs: {
+ context: this.options.context,
+ },
+ });
+ this.cacheSnippetTemplate[this.options.snippets] = this._defLoadSnippets;
+ return 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)
+ .add(this.$editor.filter(this.selectorEditableArea));
+ },
+ /**
+ * Updates the cover dimensions of the current snippet editor.
+ */
+ updateCurrentSnippetEditorOverlay: function () {
+ for (const snippetEditor of this.snippetEditors) {
+ if (snippetEditor.$target.closest('body').length) {
+ snippetEditor.cover();
+ continue;
+ }
+ // Destroy options whose $target are not in the DOM anymore but
+ // only do it once all options executions are done.
+ this._mutex.exec(() => snippetEditor.destroy());
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates drop zones in the DOM (locations where snippets may be dropped).
+ * Those locations are determined thanks to the two types of given DOM.
+ *
+ * @private
+ * @param {jQuery} [$selectorSiblings]
+ * elements which must have siblings drop zones
+ * @param {jQuery} [$selectorChildren]
+ * elements which must have child drop zones between each of existing
+ * child
+ */
+ _activateInsertionZones: function ($selectorSiblings, $selectorChildren) {
+ var self = this;
+
+ // If a modal is open, the drop zones must be created only in this modal
+ const $openModal = self.getEditableArea().find('.modal:visible');
+ if ($openModal.length) {
+ $selectorSiblings = $openModal.find($selectorSiblings);
+ $selectorChildren = $openModal.find($selectorChildren);
+ }
+
+ // Check if the drop zone should be horizontal or vertical
+ function setDropZoneDirection($elem, $parent, $sibling) {
+ var vertical = false;
+ var style = {};
+ $sibling = $sibling || $elem;
+ var css = window.getComputedStyle($elem[0]);
+ var parentCss = window.getComputedStyle($parent[0]);
+ var float = css.float || css.cssFloat;
+ var display = parentCss.display;
+ var flex = parentCss.flexDirection;
+ if (float === 'left' || float === 'right' || (display === 'flex' && flex === 'row')) {
+ style['float'] = float;
+ if ($sibling.parent().width() !== $sibling.outerWidth(true)) {
+ vertical = true;
+ style['height'] = Math.max($sibling.outerHeight(), 30) + 'px';
+ }
+ }
+ return {
+ vertical: vertical,
+ style: style,
+ };
+ }
+
+ // If the previous sibling is a BR tag or a non-whitespace text, it
+ // should be a vertical dropzone.
+ function testPreviousSibling(node, $zone) {
+ if (!node || ((node.tagName || !node.textContent.match(/\S/)) && node.tagName !== 'BR')) {
+ return false;
+ }
+ return {
+ vertical: true,
+ style: {
+ 'float': 'none',
+ 'display': 'inline-block',
+ 'height': parseInt(self.window.getComputedStyle($zone[0]).lineHeight) + 'px',
+ },
+ };
+ }
+
+ // Firstly, add a dropzone after the clone
+ var $clone = $('.oe_drop_clone');
+ if ($clone.length) {
+ var $neighbor = $clone.prev();
+ if (!$neighbor.length) {
+ $neighbor = $clone.next();
+ }
+ var data;
+ if ($neighbor.length) {
+ data = setDropZoneDirection($neighbor, $neighbor.parent());
+ } else {
+ data = {
+ vertical: false,
+ style: {},
+ };
+ }
+ self._insertDropzone($('<we-hook/>').insertAfter($clone), data.vertical, data.style);
+ }
+
+ if ($selectorChildren) {
+ $selectorChildren.each(function () {
+ var data;
+ var $zone = $(this);
+ var $children = $zone.find('> :not(.oe_drop_zone, .oe_drop_clone)');
+
+ if (!$zone.children().last().is('.oe_drop_zone')) {
+ data = testPreviousSibling($zone[0].lastChild, $zone)
+ || setDropZoneDirection($zone, $zone, $children.last());
+ self._insertDropzone($('<we-hook/>').appendTo($zone), data.vertical, data.style);
+ }
+
+ if (!$zone.children().first().is('.oe_drop_clone')) {
+ data = testPreviousSibling($zone[0].firstChild, $zone)
+ || setDropZoneDirection($zone, $zone, $children.first());
+ self._insertDropzone($('<we-hook/>').prependTo($zone), data.vertical, data.style);
+ }
+ });
+
+ // add children near drop zone
+ $selectorSiblings = $(_.uniq(($selectorSiblings || $()).add($selectorChildren.children()).get()));
+ }
+
+ var noDropZonesSelector = '[data-invisible="1"], .o_we_no_overlay, :not(:visible)';
+ if ($selectorSiblings) {
+ $selectorSiblings.not(`.oe_drop_zone, .oe_drop_clone, ${noDropZonesSelector}`).each(function () {
+ var data;
+ var $zone = $(this);
+ var $zoneToCheck = $zone;
+
+ while ($zoneToCheck.prev(noDropZonesSelector).length) {
+ $zoneToCheck = $zoneToCheck.prev();
+ }
+ if (!$zoneToCheck.prev('.oe_drop_zone:visible, .oe_drop_clone').length) {
+ data = setDropZoneDirection($zone, $zone.parent());
+ self._insertDropzone($('<we-hook/>').insertBefore($zone), data.vertical, data.style);
+ }
+
+ $zoneToCheck = $zone;
+ while ($zoneToCheck.next(noDropZonesSelector).length) {
+ $zoneToCheck = $zoneToCheck.next();
+ }
+ if (!$zoneToCheck.next('.oe_drop_zone:visible, .oe_drop_clone').length) {
+ data = setDropZoneDirection($zone, $zone.parent());
+ self._insertDropzone($('<we-hook/>').insertAfter($zone), data.vertical, data.style);
+ }
+ });
+ }
+
+ var count;
+ var $zones;
+ do {
+ count = 0;
+ $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.getEditableArea().find('.oe_drop_zone:not(.oe_vertical)');
+ $zones.each(function () {
+ var zone = $(this);
+ var prev = zone.prev();
+ var next = zone.next();
+ // remove consecutive zone
+ if (prev.is('.oe_drop_zone') || next.is('.oe_drop_zone')) {
+ zone.remove();
+ return;
+ }
+ var floatPrev = prev.css('float') || 'none';
+ var floatNext = next.css('float') || 'none';
+ var dispPrev = prev.css('display') || null;
+ var dispNext = next.css('display') || null;
+ if ((floatPrev === 'left' || floatPrev === 'right')
+ && (floatNext === 'left' || floatNext === 'right')) {
+ zone.remove();
+ } else if (dispPrev !== null && dispNext !== null
+ && dispPrev.indexOf('inline') >= 0 && dispNext.indexOf('inline') >= 0) {
+ zone.remove();
+ }
+ });
+ },
+ /**
+ * Adds an entry for every invisible snippet in the left panel box.
+ * The entries will contains an 'Edit' button to activate their snippet.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _updateInvisibleDOM: function () {
+ return this._execWithLoadingEffect(() => {
+ this.invisibleDOMMap = new Map();
+ const $invisibleDOMPanelEl = $(this.invisibleDOMPanelEl);
+ $invisibleDOMPanelEl.find('.o_we_invisible_entry').remove();
+ const $invisibleSnippets = globalSelector.all().find('.o_snippet_invisible').addBack('.o_snippet_invisible');
+
+ $invisibleDOMPanelEl.toggleClass('d-none', !$invisibleSnippets.length);
+
+ const proms = _.map($invisibleSnippets, async el => {
+ const editor = await this._createSnippetEditor($(el));
+ const $invisEntry = $('<div/>', {
+ class: 'o_we_invisible_entry d-flex align-items-center justify-content-between',
+ text: editor.getName(),
+ }).append($('<i/>', {class: `fa ${editor.isTargetVisible() ? 'fa-eye' : 'fa-eye-slash'} ml-2`}));
+ $invisibleDOMPanelEl.append($invisEntry);
+ this.invisibleDOMMap.set($invisEntry[0], el);
+ });
+ return Promise.all(proms);
+ }, false);
+ },
+ /**
+ * Disable the overlay editor of the active snippet and activate the new one
+ * if given.
+ * Note 1: if the snippet editor associated to the given snippet is not
+ * created yet, this method will create it.
+ * Note 2: if the given DOM element is not a snippet (no editor option), the
+ * first parent which is one is used instead.
+ *
+ * @param {jQuery|false} $snippet
+ * The DOM element whose editor (and its parent ones) need to be
+ * enabled. Only disable the current one if false is given.
+ * @param {boolean} [previewMode=false]
+ * @param {boolean} [ifInactiveOptions=false]
+ * @returns {Promise<SnippetEditor>}
+ * (might be async when an editor must be created)
+ */
+ _activateSnippet: async function ($snippet, previewMode, ifInactiveOptions) {
+ if (this._blockPreviewOverlays && previewMode) {
+ return;
+ }
+ if ($snippet && !$snippet.is(':visible')) {
+ return;
+ }
+ // Take the first parent of the provided DOM (or itself) which
+ // should have an associated snippet editor.
+ // It is important to do that before the mutex exec call to compute it
+ // before potential ancestor removal.
+ if ($snippet && $snippet.length) {
+ $snippet = globalSelector.closest($snippet);
+ }
+ const exec = previewMode
+ ? action => this._mutex.exec(action)
+ : action => this._execWithLoadingEffect(action, false);
+ return exec(() => {
+ return new Promise(resolve => {
+ if ($snippet && $snippet.length) {
+ return this._createSnippetEditor($snippet).then(resolve);
+ }
+ resolve(null);
+ }).then(async editorToEnable => {
+ if (ifInactiveOptions && this._enabledEditorHierarchy.includes(editorToEnable)) {
+ return editorToEnable;
+ }
+
+ if (!previewMode) {
+ this._enabledEditorHierarchy = [];
+ let current = editorToEnable;
+ while (current && current.$target) {
+ this._enabledEditorHierarchy.push(current);
+ current = current.getParent();
+ }
+ }
+
+ // First disable all editors...
+ for (let i = this.snippetEditors.length; i--;) {
+ const editor = this.snippetEditors[i];
+ editor.toggleOverlay(false, previewMode);
+ if (!previewMode && !this._enabledEditorHierarchy.includes(editor)) {
+ await editor.toggleOptions(false);
+ }
+ }
+ // ... if no editors are to be enabled, look if any have been
+ // enabled previously by a click
+ if (!editorToEnable) {
+ editorToEnable = this.snippetEditors.find(editor => editor.isSticky());
+ previewMode = false;
+ }
+ // ... then enable the right editor
+ if (editorToEnable) {
+ editorToEnable.toggleOverlay(true, previewMode);
+ await editorToEnable.toggleOptions(true);
+ }
+
+ return editorToEnable;
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {boolean} invalidateCache
+ */
+ _loadSnippetsTemplates: async function (invalidateCache) {
+ return this._execWithLoadingEffect(async () => {
+ await this._destroyEditors();
+ const html = await this.loadSnippets(invalidateCache);
+ await this._computeSnippetTemplates(html);
+ }, false);
+ },
+ /**
+ * @private
+ * @param {jQuery|null|undefined} [$el]
+ * The DOM element whose inside editors need to be destroyed.
+ * If no element is given, all the editors are destroyed.
+ */
+ _destroyEditors: async function ($el) {
+ const proms = _.map(this.snippetEditors, async function (snippetEditor) {
+ if ($el && !$el.has(snippetEditor.$target).length) {
+ return;
+ }
+ await snippetEditor.cleanForSave();
+ snippetEditor.destroy();
+ });
+ await Promise.all(proms);
+ this.snippetEditors.splice(0);
+ },
+ /**
+ * Calls a given callback 'on' the given snippet and all its child ones if
+ * any (DOM element with options).
+ *
+ * Note: the method creates the snippet editors if they do not exist yet.
+ *
+ * @private
+ * @param {jQuery} $snippet
+ * @param {function} callback
+ * Given two arguments: the snippet editor associated to the snippet
+ * being managed and the DOM element of this snippet.
+ * @returns {Promise} (might be async if snippet editors need to be created
+ * and/or the callback is async)
+ */
+ _callForEachChildSnippet: function ($snippet, callback) {
+ var self = this;
+ var defs = _.map($snippet.add(globalSelector.all($snippet)), function (el) {
+ var $snippet = $(el);
+ return self._createSnippetEditor($snippet).then(function (editor) {
+ if (editor) {
+ return callback.call(self, editor, $snippet);
+ }
+ });
+ });
+ return Promise.all(defs);
+ },
+ /**
+ * @private
+ */
+ _closeWidgets: function () {
+ this.snippetEditors.forEach(editor => editor.closeWidgets());
+ },
+ /**
+ * Creates and returns a set of helper functions which can help finding
+ * snippets in the DOM which match some parameters (typically parameters
+ * given by a snippet option). The functions are:
+ *
+ * - `is`: to determine if a given DOM is a snippet that matches the
+ * parameters
+ *
+ * - `closest`: find closest parent (or itself) of a given DOM which is a
+ * snippet that matches the parameters
+ *
+ * - `all`: find all snippets in the DOM that match the parameters
+ *
+ * See implementation for function details.
+ *
+ * @private
+ * @param {string} selector
+ * jQuery selector that DOM elements must match to be considered as
+ * potential snippet.
+ * @param {string} exclude
+ * jQuery selector that DOM elements must *not* match to be
+ * considered as potential snippet.
+ * @param {string|false} target
+ * jQuery selector that at least one child of a DOM element must
+ * match to that DOM element be considered as a potential snippet.
+ * @param {boolean} noCheck
+ * true if DOM elements which are technically not in an editable
+ * environment may be considered.
+ * @param {boolean} isChildren
+ * when the DOM elements must be in an editable environment to be
+ * considered (@see noCheck), this is true if the DOM elements'
+ * parent must also be in an editable environment to be considered.
+ */
+ _computeSelectorFunctions: function (selector, exclude, target, noCheck, isChildren) {
+ var self = this;
+
+ exclude += `${exclude && ', '}.o_snippet_not_selectable`;
+
+ let filterFunc = function () {
+ return !$(this).is(exclude);
+ };
+ if (target) {
+ const oldFilter = filterFunc;
+ filterFunc = function () {
+ return oldFilter.apply(this) && $(this).find(target).length !== 0;
+ };
+ }
+
+ // Prepare the functions
+ var functions = {
+ is: function ($from) {
+ return $from.is(selector) && $from.filter(filterFunc).length !== 0;
+ },
+ };
+ if (noCheck) {
+ functions.closest = function ($from, parentNode) {
+ return $from.closest(selector, parentNode).filter(filterFunc);
+ };
+ functions.all = function ($from) {
+ return ($from ? dom.cssFind($from, selector) : $(selector)).filter(filterFunc);
+ };
+ } else {
+ functions.closest = function ($from, parentNode) {
+ var parents = self.getEditableArea().get();
+ return $from.closest(selector, parentNode).filter(function () {
+ var node = this;
+ while (node.parentNode) {
+ if (parents.indexOf(node) !== -1) {
+ return true;
+ }
+ node = node.parentNode;
+ }
+ return false;
+ }).filter(filterFunc);
+ };
+ functions.all = isChildren ? function ($from) {
+ return dom.cssFind($from || self.getEditableArea(), selector).filter(filterFunc);
+ } : function ($from) {
+ $from = $from || self.getEditableArea();
+ return $from.filter(selector).add(dom.cssFind($from, selector)).filter(filterFunc);
+ };
+ }
+ return functions;
+ },
+ /**
+ * Processes the given snippet template to register snippet options, creates
+ * draggable thumbnail, etc.
+ *
+ * @private
+ * @param {string} html
+ */
+ _computeSnippetTemplates: function (html) {
+ var self = this;
+ var $html = $(html);
+ var $scroll = $html.siblings('#o_scroll');
+
+ // TODO remove me in master: introduced in a 14.0 fix to allow users to
+ // customize their navbar with 'Boxed' website header, which they could
+ // not because of a wrong XML selector they may not update.
+ const $headerNavFix = $html.find('[data-js="HeaderNavbar"][data-selector="#wrapwrap > header > nav"]');
+ if ($headerNavFix.length) {
+ $headerNavFix[0].dataset.selector = '#wrapwrap > header nav.navbar';
+ }
+
+ this.templateOptions = [];
+ var selectors = [];
+ var $styles = $html.find('[data-selector]');
+ $styles.each(function () {
+ var $style = $(this);
+ var selector = $style.data('selector');
+ var exclude = $style.data('exclude') || '';
+ var target = $style.data('target');
+ var noCheck = $style.data('no-check');
+ var optionID = $style.data('js') || $style.data('option-name'); // used in tour js as selector
+ var option = {
+ 'option': optionID,
+ 'base_selector': selector,
+ 'base_exclude': exclude,
+ 'base_target': target,
+ 'selector': self._computeSelectorFunctions(selector, exclude, target, noCheck),
+ '$el': $style,
+ 'drop-near': $style.data('drop-near') && self._computeSelectorFunctions($style.data('drop-near'), '', false, noCheck, true),
+ 'drop-in': $style.data('drop-in') && self._computeSelectorFunctions($style.data('drop-in'), '', false, noCheck),
+ 'data': _.extend({string: $style.attr('string')}, $style.data()),
+ };
+ self.templateOptions.push(option);
+ selectors.push(option.selector);
+ });
+ $styles.addClass('d-none');
+
+ globalSelector.closest = function ($from) {
+ var $temp;
+ var $target;
+ for (var i = 0, len = selectors.length; i < len; i++) {
+ $temp = selectors[i].closest($from, $target && $target[0]);
+ if ($temp.length) {
+ $target = $temp;
+ }
+ }
+ return $target || $();
+ };
+ globalSelector.all = function ($from) {
+ var $target = $();
+ for (var i = 0, len = selectors.length; i < len; i++) {
+ $target = $target.add(selectors[i].all($from));
+ }
+ return $target;
+ };
+ globalSelector.is = function ($from) {
+ for (var i = 0, len = selectors.length; i < len; i++) {
+ if (selectors[i].is($from)) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ this.$snippets = $scroll.find('.o_panel_body').children()
+ .addClass('oe_snippet')
+ .each((i, el) => {
+ const $snippet = $(el);
+ const name = _.escape(el.getAttribute('name'));
+ const thumbnailSrc = _.escape(el.dataset.oeThumbnail);
+ const $sbody = $snippet.children().addClass('oe_snippet_body');
+ const isCustomSnippet = !!el.closest('#snippet_custom');
+
+ // Associate in-page snippets to their name
+ // TODO I am not sure this is useful anymore and it should at
+ // least be made more robust using data-snippet
+ let snippetClasses = $sbody.attr('class').match(/s_[^ ]+/g);
+ if (snippetClasses && snippetClasses.length) {
+ snippetClasses = '.' + snippetClasses.join('.');
+ }
+ const $els = $(snippetClasses).not('[data-name]').add($sbody);
+ $els.attr('data-name', name).data('name', name);
+
+ // Create the thumbnail
+ const $thumbnail = $(`
+ <div class="oe_snippet_thumbnail">
+ <div class="oe_snippet_thumbnail_img" style="background-image: url(${thumbnailSrc});"/>
+ <span class="oe_snippet_thumbnail_title">${name}</span>
+ </div>
+ `);
+ $snippet.prepend($thumbnail);
+
+ // Create the install button (t-install feature) if necessary
+ const moduleID = $snippet.data('moduleId');
+ if (moduleID) {
+ el.classList.add('o_snippet_install');
+ $thumbnail.append($('<button/>', {
+ class: 'btn btn-primary o_install_btn w-100',
+ type: 'button',
+ text: _t("Install"),
+ }));
+ }
+
+ // Create the delete button for custom snippets
+ if (isCustomSnippet) {
+ const btnEl = document.createElement('we-button');
+ btnEl.dataset.snippetId = $snippet.data('oeSnippetId');
+ btnEl.classList.add('o_delete_btn', 'fa', 'fa-trash', 'btn', 'o_we_hover_danger');
+ btnEl.title = _.str.sprintf(_t("Delete %s"), name);
+ $snippet.append(btnEl);
+ }
+ })
+ .not('[data-module-id]');
+
+ // Hide scroll if no snippets defined
+ if (!this.$snippets.length) {
+ this.$el.detach();
+ }
+
+ // Register the text nodes that needs to be auto-selected on click
+ this._registerDefaultTexts();
+
+ // Force non editable part to contentEditable=false
+ $html.find('.o_not_editable').attr('contentEditable', false);
+
+ // Add the computed template and make elements draggable
+ this.$el.html($html);
+ this.$el.append(this.customizePanel);
+ this.$el.append(this.textEditorPanelEl);
+ this.$el.append(this.invisibleDOMPanelEl);
+ this._makeSnippetDraggable(this.$snippets);
+ this._disableUndroppableSnippets();
+
+ this.$el.addClass('o_loaded');
+ $('body.editor_enable').addClass('editor_has_snippets');
+ this.trigger_up('snippets_loaded', self.$el);
+ },
+ /**
+ * Creates a snippet editor to associated to the given snippet. If the given
+ * snippet already has a linked snippet editor, the function only returns
+ * that one.
+ * The function also instantiates a snippet editor for all snippet parents
+ * as a snippet editor must be able to display the parent snippet options.
+ *
+ * @private
+ * @param {jQuery} $snippet
+ * @returns {Promise<SnippetEditor>}
+ */
+ _createSnippetEditor: function ($snippet) {
+ var self = this;
+ var snippetEditor = $snippet.data('snippet-editor');
+ if (snippetEditor) {
+ return snippetEditor.__isStarted;
+ }
+
+ var def;
+ var $parent = globalSelector.closest($snippet.parent());
+ if ($parent.length) {
+ def = this._createSnippetEditor($parent);
+ }
+
+ return Promise.resolve(def).then(function (parentEditor) {
+ // When reaching this position, after the Promise resolution, the
+ // snippet editor instance might have been created by another call
+ // to _createSnippetEditor... the whole logic should be improved
+ // to avoid doing this here.
+ snippetEditor = $snippet.data('snippet-editor');
+ if (snippetEditor) {
+ return snippetEditor.__isStarted;
+ }
+
+ let editableArea = self.getEditableArea();
+ snippetEditor = new SnippetEditor(parentEditor || self, $snippet, self.templateOptions, $snippet.closest('[data-oe-type="html"], .oe_structure').add(editableArea), self.options);
+ self.snippetEditors.push(snippetEditor);
+ return snippetEditor.appendTo(self.$snippetEditorArea);
+ }).then(function () {
+ return snippetEditor;
+ });
+ },
+ /**
+ * There may be no location where some snippets might be dropped. This mades
+ * them appear disabled in the menu.
+ *
+ * @todo make them undraggable
+ * @private
+ */
+ _disableUndroppableSnippets: function () {
+ var self = this;
+ var cache = {};
+ this.$snippets.each(function () {
+ var $snippet = $(this);
+ var $snippetBody = $snippet.find('.oe_snippet_body');
+
+ var check = false;
+ _.each(self.templateOptions, function (option, k) {
+ if (check || !($snippetBody.is(option.base_selector) && !$snippetBody.is(option.base_exclude))) {
+ return;
+ }
+
+ cache[k] = cache[k] || {
+ 'drop-near': option['drop-near'] ? option['drop-near'].all().length : 0,
+ 'drop-in': option['drop-in'] ? option['drop-in'].all().length : 0
+ };
+ check = (cache[k]['drop-near'] || cache[k]['drop-in']);
+ });
+
+ $snippet.toggleClass('o_disabled', !check);
+ $snippet.attr('title', check ? '' : _t("No location to drop in"));
+ const $icon = $snippet.find('.o_snippet_undroppable').remove();
+ if (check) {
+ $icon.remove();
+ } else if (!$icon.length) {
+ const imgEl = document.createElement('img');
+ imgEl.classList.add('o_snippet_undroppable');
+ imgEl.src = '/web_editor/static/src/img/snippet_disabled.svg';
+ $snippet.append(imgEl);
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {string} [search]
+ */
+ _filterSnippets(search) {
+ const searchInputEl = this.el.querySelector('.o_snippet_search_filter_input');
+ const searchInputReset = this.el.querySelector('.o_snippet_search_filter_reset');
+ if (search !== undefined) {
+ searchInputEl.value = search;
+ } else {
+ search = searchInputEl.value;
+ }
+ search = search.toLowerCase();
+ searchInputReset.classList.toggle('d-none', !search);
+ const strMatches = str => !search || str.toLowerCase().includes(search);
+ for (const panelEl of this.el.querySelectorAll('.o_panel')) {
+ let hasVisibleSnippet = false;
+ const panelTitle = panelEl.querySelector('.o_panel_header').textContent;
+ const isPanelTitleMatch = strMatches(panelTitle);
+ for (const snippetEl of panelEl.querySelectorAll('.oe_snippet')) {
+ const matches = (isPanelTitleMatch
+ || strMatches(snippetEl.getAttribute('name'))
+ || strMatches(snippetEl.dataset.oeKeywords || ''));
+ if (matches) {
+ hasVisibleSnippet = true;
+ }
+ snippetEl.classList.toggle('d-none', !matches);
+ }
+ panelEl.classList.toggle('d-none', !hasVisibleSnippet);
+ }
+ },
+ /**
+ * @private
+ * @param {Object} [options={}]
+ * @returns {Object}
+ */
+ _getScrollOptions(options = {}) {
+ return Object.assign({}, options, {
+ scrollBoundaries: Object.assign({
+ right: false,
+ }, options.scrollBoundaries),
+ jQueryDraggableOptions: Object.assign({
+ appendTo: this.$body,
+ cursor: 'move',
+ greedy: true,
+ scroll: false,
+ }, options.jQueryDraggableOptions),
+ disableHorizontalScroll: true,
+ });
+ },
+ /**
+ * Creates a dropzone element and inserts it by replacing the given jQuery
+ * location. This allows to add data on the dropzone depending on the hook
+ * environment.
+ *
+ * @private
+ * @param {jQuery} $hook
+ * @param {boolean} [vertical=false]
+ * @param {Object} [style]
+ */
+ _insertDropzone: function ($hook, vertical, style) {
+ var $dropzone = $('<div/>', {
+ 'class': 'oe_drop_zone oe_insert' + (vertical ? ' oe_vertical' : ''),
+ });
+ if (style) {
+ $dropzone.css(style);
+ }
+ $hook.replaceWith($dropzone);
+ return $dropzone;
+ },
+ /**
+ * Make given snippets be draggable/droppable thanks to their thumbnail.
+ *
+ * @private
+ * @param {jQuery} $snippets
+ */
+ _makeSnippetDraggable: function ($snippets) {
+ var self = this;
+ var $toInsert, dropped, $snippet;
+
+ let dragAndDropResolve;
+ const $scrollingElement = $().getScrollingElement();
+
+ const smoothScrollOptions = this._getScrollOptions({
+ jQueryDraggableOptions: {
+ handle: '.oe_snippet_thumbnail:not(.o_we_already_dragging)',
+ helper: function () {
+ const dragSnip = this.cloneNode(true);
+ dragSnip.querySelectorAll('.o_delete_btn').forEach(
+ el => el.remove()
+ );
+ return dragSnip;
+ },
+ start: function () {
+ self.$el.find('.oe_snippet_thumbnail').addClass('o_we_already_dragging');
+
+ dropped = false;
+ $snippet = $(this);
+ var $baseBody = $snippet.find('.oe_snippet_body');
+ var $selectorSiblings = $();
+ var $selectorChildren = $();
+ var temp = self.templateOptions;
+ for (var k in temp) {
+ if ($baseBody.is(temp[k].base_selector) && !$baseBody.is(temp[k].base_exclude)) {
+ if (temp[k]['drop-near']) {
+ $selectorSiblings = $selectorSiblings.add(temp[k]['drop-near'].all());
+ }
+ if (temp[k]['drop-in']) {
+ $selectorChildren = $selectorChildren.add(temp[k]['drop-in'].all());
+ }
+ }
+ }
+
+ $toInsert = $baseBody.clone();
+ // Color-customize dynamic SVGs in dropped snippets with current theme colors.
+ [...$toInsert.find('img[src^="/web_editor/shape/"]')].forEach(dynamicSvg => {
+ const colorCustomizedURL = new URL(dynamicSvg.getAttribute('src'), window.location.origin);
+ colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1'));
+ dynamicSvg.src = colorCustomizedURL.pathname + colorCustomizedURL.search;
+ });
+
+ if (!$selectorSiblings.length && !$selectorChildren.length) {
+ console.warn($snippet.find('.oe_snippet_thumbnail_title').text() + " have not insert action: data-drop-near or data-drop-in");
+ return;
+ }
+
+ self._activateInsertionZones($selectorSiblings, $selectorChildren);
+
+ self.getEditableArea().find('.oe_drop_zone').droppable({
+ over: function () {
+ if (dropped) {
+ $toInsert.detach();
+ $toInsert.addClass('oe_snippet_body');
+ $('.oe_drop_zone').removeClass('invisible');
+ }
+ dropped = true;
+ $(this).first().after($toInsert).addClass('invisible');
+ $toInsert.removeClass('oe_snippet_body');
+ },
+ out: function () {
+ var prev = $toInsert.prev();
+ if (this === prev[0]) {
+ dropped = false;
+ $toInsert.detach();
+ $(this).removeClass('invisible');
+ $toInsert.addClass('oe_snippet_body');
+ }
+ },
+ });
+
+ // If a modal is open, the scroll target must be that modal
+ const $openModal = self.getEditableArea().find('.modal:visible');
+ self.draggableComponent.$scrollTarget = $openModal.length ? $openModal : $scrollingElement;
+
+ // Trigger a scroll on the draggable element so that jQuery updates
+ // the position of the drop zones.
+ self.draggableComponent.$scrollTarget.on('scroll.scrolling_element', function () {
+ self.$el.trigger('scroll');
+ });
+
+ const prom = new Promise(resolve => dragAndDropResolve = () => resolve());
+ self._mutex.exec(() => prom);
+ },
+ stop: async function (ev, ui) {
+ $toInsert.removeClass('oe_snippet_body');
+ self.draggableComponent.$scrollTarget.off('scroll.scrolling_element');
+
+ if (!dropped && ui.position.top > 3 && ui.position.left + ui.helper.outerHeight() < self.el.getBoundingClientRect().left) {
+ var $el = $.nearest({x: ui.position.left, y: ui.position.top}, '.oe_drop_zone', {container: document.body}).first();
+ if ($el.length) {
+ $el.after($toInsert);
+ dropped = true;
+ }
+ }
+
+ self.getEditableArea().find('.oe_drop_zone').droppable('destroy').remove();
+
+ if (dropped) {
+ var prev = $toInsert.first()[0].previousSibling;
+ var next = $toInsert.last()[0].nextSibling;
+
+ if (prev) {
+ $toInsert.detach();
+ self.trigger_up('request_history_undo_record', {$target: $(prev)});
+ $toInsert.insertAfter(prev);
+ } else if (next) {
+ $toInsert.detach();
+ self.trigger_up('request_history_undo_record', {$target: $(next)});
+ $toInsert.insertBefore(next);
+ } else {
+ var $parent = $toInsert.parent();
+ $toInsert.detach();
+ self.trigger_up('request_history_undo_record', {$target: $parent});
+ $parent.prepend($toInsert);
+ }
+
+ var $target = $toInsert;
+ await self._scrollToSnippet($target);
+
+ _.defer(async function () {
+ self.trigger_up('snippet_dropped', {$target: $target});
+ self._disableUndroppableSnippets();
+
+ dragAndDropResolve();
+
+ await self._callForEachChildSnippet($target, function (editor, $snippet) {
+ return editor.buildSnippet();
+ });
+ $target.trigger('content_changed');
+ await self._updateInvisibleDOM();
+
+ self.$el.find('.oe_snippet_thumbnail').removeClass('o_we_already_dragging');
+ });
+ } else {
+ $toInsert.remove();
+ dragAndDropResolve();
+ self.$el.find('.oe_snippet_thumbnail').removeClass('o_we_already_dragging');
+ }
+ },
+ },
+ });
+ this.draggableComponent = new SmoothScrollOnDrag(this, $snippets, $scrollingElement, smoothScrollOptions);
+ },
+ /**
+ * Adds the 'o_default_snippet_text' class on nodes which contain only
+ * non-empty text nodes. Those nodes are then auto-selected by the editor
+ * when they are clicked.
+ *
+ * @private
+ * @param {jQuery} [$in] - the element in which to search, default to the
+ * snippet bodies in the menu
+ */
+ _registerDefaultTexts: function ($in) {
+ if ($in === undefined) {
+ $in = this.$snippets.find('.oe_snippet_body');
+ }
+
+ $in.find('*').addBack()
+ .contents()
+ .filter(function () {
+ return this.nodeType === 3 && this.textContent.match(/\S/);
+ }).parent().addClass('o_default_snippet_text');
+ },
+ /**
+ * Changes the content of the left panel and selects a tab.
+ *
+ * @private
+ * @param {htmlString | Element | Text | Array | jQuery} [content]
+ * the new content of the customizePanel
+ * @param {this.tabs.VALUE} [tab='blocks'] - the tab to select
+ */
+ _updateLeftPanelContent: function ({content, tab}) {
+ clearTimeout(this._textToolsSwitchingTimeout);
+ this._closeWidgets();
+
+ tab = tab || this.tabs.BLOCKS;
+
+ if (content) {
+ while (this.customizePanel.firstChild) {
+ this.customizePanel.removeChild(this.customizePanel.firstChild);
+ }
+ $(this.customizePanel).append(content);
+ }
+
+ this.$('.o_snippet_search_filter').toggleClass('d-none', tab !== this.tabs.BLOCKS);
+ this.$('#o_scroll').toggleClass('d-none', tab !== this.tabs.BLOCKS);
+ this.customizePanel.classList.toggle('d-none', tab === this.tabs.BLOCKS);
+ this.textEditorPanelEl.classList.toggle('d-none', tab !== this.tabs.OPTIONS);
+
+ this.$('.o_we_add_snippet_btn').toggleClass('active', tab === this.tabs.BLOCKS);
+ this.$('.o_we_customize_snippet_btn').toggleClass('active', tab === this.tabs.OPTIONS)
+ .prop('disabled', tab !== this.tabs.OPTIONS);
+
+ },
+ /**
+ * Scrolls to given snippet.
+ *
+ * @private
+ * @param {jQuery} $el - snippet to scroll to
+ * @return {Promise}
+ */
+ async _scrollToSnippet($el) {
+ return dom.scrollTo($el[0], {extraOffset: 50});
+ },
+ /**
+ * @private
+ * @returns {HTMLElement}
+ */
+ _createLoadingElement() {
+ const loaderContainer = document.createElement('div');
+ const loader = document.createElement('i');
+ const loaderContainerClassList = [
+ 'o_we_ui_loading',
+ 'd-flex',
+ 'justify-content-center',
+ 'align-items-center',
+ ];
+ const loaderClassList = [
+ 'fa',
+ 'fa-circle-o-notch',
+ 'fa-spin',
+ 'fa-4x',
+ ];
+ loaderContainer.classList.add(...loaderContainerClassList);
+ loader.classList.add(...loaderClassList);
+ loaderContainer.appendChild(loader);
+ return loaderContainer;
+ },
+ /**
+ * Adds the action to the mutex queue and sets a loading effect over the
+ * editor to appear if the action takes too much time.
+ * As soon as the mutex is unlocked, the loading effect will be removed.
+ *
+ * @private
+ * @param {function} action
+ * @param {boolean} [contentLoading=true]
+ * @param {number} [delay=500]
+ * @returns {Promise}
+ */
+ async _execWithLoadingEffect(action, contentLoading = true, delay = 500) {
+ const mutexExecResult = this._mutex.exec(action);
+ if (!this.loadingTimers[contentLoading]) {
+ const addLoader = () => {
+ this.loadingElements[contentLoading] = this._createLoadingElement();
+ if (contentLoading) {
+ this.$snippetEditorArea.append(this.loadingElements[contentLoading]);
+ } else {
+ this.el.appendChild(this.loadingElements[contentLoading]);
+ }
+ };
+ if (delay) {
+ this.loadingTimers[contentLoading] = setTimeout(addLoader, delay);
+ } else {
+ addLoader();
+ }
+ this._mutex.getUnlockedDef().then(() => {
+ // Note: we remove the loading element at the end of the
+ // execution queue *even if subsequent actions are content
+ // related or not*. This is a limitation of the loading feature,
+ // the goal is still to limit the number of elements in that
+ // queue anyway.
+ if (delay) {
+ clearTimeout(this.loadingTimers[contentLoading]);
+ this.loadingTimers[contentLoading] = undefined;
+ }
+
+ if (this.loadingElements[contentLoading]) {
+ this.loadingElements[contentLoading].remove();
+ this.loadingElements[contentLoading] = null;
+ }
+ });
+ }
+ return mutexExecResult;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a child editor asks for insertion zones to be enabled.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onActivateInsertionZones: function (ev) {
+ this._activateInsertionZones(ev.data.$selectorSiblings, ev.data.$selectorChildren);
+ },
+ /**
+ * Called when a child editor asks to deactivate the current snippet
+ * overlay.
+ *
+ * @private
+ */
+ _onActivateSnippet: function (ev) {
+ this._activateSnippet(ev.data.$snippet, ev.data.previewMode, ev.data.ifInactiveOptions);
+ },
+ /**
+ * Called when a child editor asks to operate some operation on all child
+ * snippet of a DOM element.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCallForEachChildSnippet: function (ev) {
+ this._callForEachChildSnippet(ev.data.$snippet, ev.data.callback);
+ },
+ /**
+ * Called when the overlay dimensions/positions should be recomputed.
+ *
+ * @private
+ */
+ _onOverlaysCoverUpdate: function () {
+ this.snippetEditors.forEach(editor => {
+ editor.cover();
+ });
+ },
+ /**
+ * Called when a child editor asks to clone a snippet, allows to correctly
+ * call the _onClone methods if the element's editor has one.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCloneSnippet: async function (ev) {
+ ev.stopPropagation();
+ const editor = await this._createSnippetEditor(ev.data.$snippet);
+ await editor.clone();
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ },
+ /**
+ * Called when a child editor asks to deactivate the current snippet
+ * overlay.
+ *
+ * @private
+ */
+ _onDeactivateSnippet: function () {
+ this._activateSnippet(false);
+ },
+ /**
+ * Called when a snippet has moved in the page.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDragAndDropStop: async function (ev) {
+ const $modal = ev.data.$snippet.closest('.modal');
+ // If the snippet is in a modal, destroy editors only in that modal.
+ // This to prevent the modal from closing because of the cleanForSave
+ // on each editors.
+ await this._destroyEditors($modal.length ? $modal : null);
+ await this._activateSnippet(ev.data.$snippet);
+ },
+ /**
+ * Called when a snippet editor asked to disable itself and to enable its
+ * parent instead.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGoToParent: function (ev) {
+ ev.stopPropagation();
+ this._activateSnippet(ev.data.$snippet.parent());
+ },
+ /**
+ * @private
+ */
+ _onHideOverlay: function () {
+ for (const editor of this.snippetEditors) {
+ editor.toggleOverlay(false);
+ }
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInstallBtnClick: function (ev) {
+ var self = this;
+ var $snippet = $(ev.currentTarget).closest('[data-module-id]');
+ var moduleID = $snippet.data('moduleId');
+ var name = $snippet.attr('name');
+ new Dialog(this, {
+ title: _.str.sprintf(_t("Install %s"), name),
+ size: 'medium',
+ $content: $('<div/>', {text: _.str.sprintf(_t("Do you want to install the %s App?"), name)}).append(
+ $('<a/>', {
+ target: '_blank',
+ href: '/web#id=' + moduleID + '&view_type=form&model=ir.module.module&action=base.open_module_tree',
+ text: _t("More info about this app."),
+ class: 'ml4',
+ })
+ ),
+ buttons: [{
+ text: _t("Save and Install"),
+ classes: 'btn-primary',
+ click: function () {
+ this.$footer.find('.btn').toggleClass('o_hidden');
+ this._rpc({
+ model: 'ir.module.module',
+ method: 'button_immediate_install',
+ args: [[moduleID]],
+ }).then(() => {
+ self.trigger_up('request_save', {
+ reloadEditor: true,
+ _toMutex: true,
+ });
+ }).guardedCatch(reason => {
+ reason.event.preventDefault();
+ this.close();
+ self.displayNotification({
+ message: _.str.sprintf(_t("Could not install module <strong>%s</strong>"), name),
+ type: 'danger',
+ sticky: true,
+ });
+ });
+ },
+ }, {
+ text: _t("Install in progress"),
+ icon: 'fa-spin fa-spinner fa-pulse mr8',
+ classes: 'btn-primary disabled o_hidden',
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ }],
+ }).open();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInvisibleEntryClick: async function (ev) {
+ ev.preventDefault();
+ const $snippet = $(this.invisibleDOMMap.get(ev.currentTarget));
+ const isVisible = await this._execWithLoadingEffect(async () => {
+ const editor = await this._createSnippetEditor($snippet);
+ return editor.toggleTargetVisibility();
+ }, true);
+ $(ev.currentTarget).find('.fa')
+ .toggleClass('fa-eye', isVisible)
+ .toggleClass('fa-eye-slash', !isVisible);
+ return this._activateSnippet(isVisible ? $snippet : false);
+ },
+ /**
+ * @private
+ */
+ _onBlocksTabClick: function (ev) {
+ this._activateSnippet(false).then(() => {
+ this._updateLeftPanelContent({
+ content: [],
+ tab: this.tabs.BLOCKS,
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _onDeleteBtnClick: function (ev) {
+ const $snippet = $(ev.target).closest('.oe_snippet');
+ const snippetId = parseInt(ev.currentTarget.dataset.snippetId);
+ ev.stopPropagation();
+ new Dialog(this, {
+ size: 'medium',
+ title: _t('Confirmation'),
+ $content: $('<div><p>' + _.str.sprintf(_t("Are you sure you want to delete the snippet: %s ?"), $snippet.attr('name')) + '</p></div>'),
+ buttons: [{
+ text: _t("Yes"),
+ close: true,
+ classes: 'btn-primary',
+ click: async () => {
+ await this._rpc({
+ model: 'ir.ui.view',
+ method: 'delete_snippet',
+ kwargs: {
+ 'view_id': snippetId,
+ 'template_key': this.options.snippets,
+ },
+ });
+ await this._loadSnippetsTemplates(true);
+ },
+ }, {
+ text: _t("No"),
+ close: true,
+ }],
+ }).open();
+ },
+ /**
+ * Prevents pointer-events to change the focus when a pointer slide from
+ * left-panel to the editable area.
+ *
+ * @private
+ */
+ _onMouseDown: function () {
+ const $blockedArea = $('#wrapwrap'); // TODO should get that element another way
+ $blockedArea.addClass('o_we_no_pointer_events');
+ const reenable = () => $blockedArea.removeClass('o_we_no_pointer_events');
+ // Use a setTimeout fallback to avoid locking the editor if the mouseup
+ // is fired over an element which stops propagation for example.
+ const enableTimeoutID = setTimeout(() => reenable(), 5000);
+ $(document).one('mouseup', () => {
+ clearTimeout(enableTimeoutID);
+ reenable();
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onGetSnippetVersions: function (ev) {
+ const snippet = this.el.querySelector(`.oe_snippet > [data-snippet="${ev.data.snippetName}"]`);
+ ev.data.onSuccess(snippet && {
+ vcss: snippet.dataset.vcss,
+ vjs: snippet.dataset.vjs,
+ vxml: snippet.dataset.vxml,
+ });
+ },
+ /**
+ * UNUSED: used to be called when saving a custom snippet. We now save and
+ * reload the page when saving a custom snippet so that all the DOM cleanup
+ * mechanisms are run before saving. Kept for compatibility.
+ *
+ * TODO: remove in master / find a way to clean the DOM without save+reload
+ *
+ * @private
+ */
+ _onReloadSnippetTemplate: async function (ev) {
+ await this._activateSnippet(false);
+ await this._loadSnippetsTemplates(true);
+ },
+ /**
+ * @private
+ */
+ _onBlockPreviewOverlays: function (ev) {
+ this._blockPreviewOverlays = true;
+ },
+ /**
+ * @private
+ */
+ _onUnblockPreviewOverlays: function (ev) {
+ this._blockPreviewOverlays = false;
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRemoveSnippet: async function (ev) {
+ ev.stopPropagation();
+ const editor = await this._createSnippetEditor(ev.data.$snippet);
+ await editor.removeSnippet();
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ },
+ /**
+ * Saving will destroy all editors since they need to clean their DOM.
+ * This has thus to be done when they are all finished doing their work.
+ *
+ * @private
+ */
+ _onSaveRequest: function (ev) {
+ const data = ev.data;
+ if (ev.target === this && !data._toMutex) {
+ return;
+ }
+ delete data._toMutex;
+ ev.stopPropagation();
+ this._execWithLoadingEffect(() => {
+ if (data.reloadEditor) {
+ data.reload = false;
+ const oldOnSuccess = data.onSuccess;
+ data.onSuccess = async function () {
+ if (oldOnSuccess) {
+ await oldOnSuccess.call(this, ...arguments);
+ }
+ window.location.href = window.location.origin + window.location.pathname + '?enable_editor=1';
+ };
+ }
+ this.trigger_up('request_save', data);
+ }, true);
+ },
+ /**
+ * @private
+ */
+ _onSnippetClick() {
+ const $els = this.getEditableArea().find('.oe_structure.oe_empty').addBack('.oe_structure.oe_empty');
+ for (const el of $els) {
+ if (!el.children.length) {
+ $(el).odooBounce('o_we_snippet_area_animation');
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data
+ * @param {function} ev.data.exec
+ */
+ _onSnippetEditionRequest: function (ev) {
+ this._execWithLoadingEffect(ev.data.exec, true);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetEditorDestroyed(ev) {
+ ev.stopPropagation();
+ const index = this.snippetEditors.indexOf(ev.target);
+ this.snippetEditors.splice(index, 1);
+ },
+ /**
+ * @private
+ */
+ _onSnippetCloned: function (ev) {
+ this._updateInvisibleDOM();
+ },
+ /**
+ * Called when a snippet is removed -> checks if there is draggable snippets
+ * to enable/disable as the DOM changed.
+ *
+ * @private
+ */
+ _onSnippetRemoved: function () {
+ this._disableUndroppableSnippets();
+ this._updateInvisibleDOM();
+ },
+ /**
+ * When the editor panel receives a notification indicating that an option
+ * was used, the panel is in charge of asking for an UI update of the whole
+ * panel. Logically, the options are displayed so that an option above
+ * may influence the status and visibility of an option which is below;
+ * e.g.:
+ * - the user sets a badge type to 'info'
+ * -> the badge background option (below) is shown as blue
+ * - the user adds a shadow
+ * -> more options are shown afterwards to control it (not above)
+ *
+ * Technically we however update the whole editor panel (parent and child
+ * options) wherever the updates comes from. The only important thing is
+ * to first update the options UI then their visibility as their visibility
+ * may depend on their UI status.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionUpdate(ev) {
+ ev.stopPropagation();
+ (async () => {
+ const editors = this._enabledEditorHierarchy;
+ await Promise.all(editors.map(editor => editor.updateOptionsUI()));
+ await Promise.all(editors.map(editor => editor.updateOptionsUIVisibility()));
+ ev.data.onSuccess();
+ })();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetOptionVisibilityUpdate: async function (ev) {
+ if (!ev.data.show) {
+ await this._activateSnippet(false);
+ }
+ await this._updateInvisibleDOM(); // Re-render to update status
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSnippetThumbnailURLRequest(ev) {
+ const $snippet = this.$snippets.has(`[data-snippet="${ev.data.key}"]`);
+ ev.data.onSuccess($snippet.length ? $snippet[0].dataset.oeThumbnail : '');
+ },
+ /**
+ * @private
+ */
+ _onSummernoteToolsUpdate(ev) {
+ if (!this._textToolsSwitchingEnabled) {
+ return;
+ }
+ const range = $.summernote.core.range.create();
+ if (!range) {
+ return;
+ }
+ if (range.sc === range.ec && range.sc.nodeType === Node.ELEMENT_NODE
+ && range.sc.classList.contains('oe_structure')
+ && range.sc.children.length === 0) {
+ // Do not switch to text tools if the cursor is in an empty
+ // oe_structure (to encourage using snippets there and actually
+ // avoid breaking tours which suppose the snippet list is visible).
+ return;
+ }
+ this.textEditorPanelEl.classList.add('d-block');
+ const hasVisibleButtons = !!$(this.textEditorPanelEl).find('.btn:visible').length;
+ this.textEditorPanelEl.classList.remove('d-block');
+ if (!hasVisibleButtons) {
+ // Ugly way to detect that summernote was updated but there is no
+ // visible text tools.
+ return;
+ }
+ // Only switch tab without changing content (_updateLeftPanelContent
+ // make text tools visible only on that specific tab). Also do it with
+ // a slight delay to avoid flickering doing it twice.
+ clearTimeout(this._textToolsSwitchingTimeout);
+ if (!this.$('#o_scroll').hasClass('d-none')) {
+ this._textToolsSwitchingTimeout = setTimeout(() => {
+ this._updateLeftPanelContent({tab: this.tabs.OPTIONS});
+ }, 250);
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onUpdateCustomizeElements: function (ev) {
+ this._updateLeftPanelContent({
+ content: ev.data.customize$Elements,
+ tab: ev.data.customize$Elements.length ? this.tabs.OPTIONS : this.tabs.BLOCKS,
+ });
+ },
+ /**
+ * Called when an user value widget is being opened -> close all the other
+ * user value widgets of all editors + add backdrop.
+ */
+ _onUserValueWidgetOpening: function () {
+ this._closeWidgets();
+ this.el.classList.add('o_we_backdrop');
+ },
+ /**
+ * Called when an user value widget is being closed -> rely on the fact only
+ * one widget can be opened at a time: remove the backdrop.
+ */
+ _onUserValueWidgetClosing: function () {
+ this.el.classList.remove('o_we_backdrop');
+ },
+ /**
+ * Called when search input value changed -> adapts the snippets grid.
+ *
+ * @private
+ */
+ _onSnippetSearchInput: function () {
+ this._filterSnippets();
+ },
+ /**
+ * Called on snippet search filter reset -> clear input field search.
+ *
+ * @private
+ */
+ _onSnippetSearchResetClick: function () {
+ this._filterSnippets('');
+ },
+});
+
+return {
+ Class: SnippetsMenu,
+ Editor: SnippetEditor,
+ globalSelector: globalSelector,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js
new file mode 100644
index 00000000..f89d5791
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/snippets.options.js
@@ -0,0 +1,4908 @@
+odoo.define('web_editor.snippets.options', function (require) {
+'use strict';
+
+var core = require('web.core');
+const {ColorpickerWidget} = require('web.Colorpicker');
+const Dialog = require('web.Dialog');
+const rpc = require('web.rpc');
+const time = require('web.time');
+var Widget = require('web.Widget');
+var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget;
+const weUtils = require('web_editor.utils');
+const {
+ normalizeColor,
+ getBgImageURL,
+} = weUtils;
+var weWidgets = require('wysiwyg.widgets');
+const {
+ loadImage,
+ loadImageInfo,
+ applyModifications,
+ removeOnImageChangeAttrs,
+} = require('web_editor.image_processing');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+/**
+ * @param {HTMLElement} el
+ * @param {string} [title]
+ * @param {Object} [options]
+ * @param {string[]} [options.classes]
+ * @param {string} [options.tooltip]
+ * @param {string} [options.placeholder]
+ * @param {Object} [options.dataAttributes]
+ * @returns {HTMLElement} - the original 'el' argument
+ */
+function _addTitleAndAllowedAttributes(el, title, options) {
+ let tooltipEl = el;
+ if (title) {
+ const titleEl = _buildTitleElement(title);
+ tooltipEl = titleEl;
+ el.appendChild(titleEl);
+ }
+
+ if (options && options.classes) {
+ el.classList.add(...options.classes);
+ }
+ if (options && options.tooltip) {
+ tooltipEl.title = options.tooltip;
+ }
+ if (options && options.placeholder) {
+ el.setAttribute('placeholder', options.placeholder);
+ }
+ if (options && options.dataAttributes) {
+ for (const key in options.dataAttributes) {
+ el.dataset[key] = options.dataAttributes[key];
+ }
+ }
+
+ return el;
+}
+/**
+ * @param {string} tagName
+ * @param {string} title - @see _addTitleAndAllowedAttributes
+ * @param {Object} options - @see _addTitleAndAllowedAttributes
+ * @returns {HTMLElement}
+ */
+function _buildElement(tagName, title, options) {
+ const el = document.createElement(tagName);
+ return _addTitleAndAllowedAttributes(el, title, options);
+}
+/**
+ * @param {string} title
+ * @returns {HTMLElement}
+ */
+function _buildTitleElement(title) {
+ const titleEl = document.createElement('we-title');
+ titleEl.textContent = title;
+ return titleEl;
+}
+/**
+ * @param {string} src
+ * @returns {HTMLElement}
+ */
+const _buildImgElementCache = {};
+async function _buildImgElement(src) {
+ if (!(src in _buildImgElementCache)) {
+ _buildImgElementCache[src] = (async () => {
+ if (src.split('.').pop() === 'svg') {
+ const response = await window.fetch(src);
+ const text = await response.text();
+ const parser = new window.DOMParser();
+ const xmlDoc = parser.parseFromString(text, 'text/xml');
+ return xmlDoc.getElementsByTagName('svg')[0];
+ } else {
+ const imgEl = document.createElement('img');
+ imgEl.src = src;
+ return imgEl;
+ }
+ })();
+ }
+ const node = await _buildImgElementCache[src];
+ return node.cloneNode(true);
+}
+/**
+ * Build the correct DOM for a we-row element.
+ *
+ * @param {string} [title] - @see _buildElement
+ * @param {Object} [options] - @see _buildElement
+ * @param {HTMLElement[]} [options.childNodes]
+ * @returns {HTMLElement}
+ */
+function _buildRowElement(title, options) {
+ const groupEl = _buildElement('we-row', title, options);
+
+ const rowEl = document.createElement('div');
+ groupEl.appendChild(rowEl);
+
+ if (options && options.childNodes) {
+ options.childNodes.forEach(node => rowEl.appendChild(node));
+ }
+
+ return groupEl;
+}
+/**
+ * Build the correct DOM for a we-collapse element.
+ *
+ * @param {string} [title] - @see _buildElement
+ * @param {Object} [options] - @see _buildElement
+ * @param {HTMLElement[]} [options.childNodes]
+ * @returns {HTMLElement}
+ */
+function _buildCollapseElement(title, options) {
+ const groupEl = _buildElement('we-collapse', title, options);
+ const titleEl = groupEl.querySelector('we-title');
+
+ const children = options && options.childNodes || [];
+ if (titleEl) {
+ titleEl.remove();
+ children.unshift(titleEl);
+ }
+ let i = 0;
+ for (i = 0; i < children.length; i++) {
+ groupEl.appendChild(children[i]);
+ if (children[i].nodeType === Node.ELEMENT_NODE) {
+ break;
+ }
+ }
+
+ const togglerEl = document.createElement('we-toggler');
+ togglerEl.classList.add('o_we_collapse_toggler');
+ groupEl.appendChild(togglerEl);
+
+ const containerEl = document.createElement('div');
+ children.slice(i + 1).forEach(node => containerEl.appendChild(node));
+ groupEl.appendChild(containerEl);
+
+ return groupEl;
+}
+/**
+ * Creates a proxy for an object where one property is replaced by a different
+ * value. This value is captured in the closure and can be read and written to.
+ *
+ * @param {Object} obj - the object for which to create a proxy
+ * @param {string} propertyName - the name/key of the property to replace
+ * @param {*} value - the initial value to give to the property's copy
+ * @returns {Proxy} a proxy of the object with the property replaced
+ */
+function createPropertyProxy(obj, propertyName, value) {
+ return new Proxy(obj, {
+ get: function (obj, prop) {
+ if (prop === propertyName) {
+ return value;
+ }
+ return obj[prop];
+ },
+ set: function (obj, prop, val) {
+ if (prop === propertyName) {
+ return (value = val);
+ }
+ return Reflect.set(...arguments);
+ },
+ });
+}
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+const NULL_ID = '__NULL__';
+
+/**
+ * Base class for components to be used in snippet options widgets to retrieve
+ * user values.
+ */
+const UserValueWidget = Widget.extend({
+ className: 'o_we_user_value_widget',
+ custom_events: {
+ 'user_value_update': '_onUserValueNotification',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function (parent, title, options, $target) {
+ this._super(...arguments);
+ this.title = title;
+ this.options = options;
+ this._userValueWidgets = [];
+ this._value = '';
+ this.$target = $target;
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super(...arguments);
+ if (this.options.dataAttributes.img) {
+ this.imgEl = await _buildImgElement(this.options.dataAttributes.img);
+ }
+ },
+ /**
+ * @override
+ */
+ _makeDescriptive: function () {
+ const $el = this._super(...arguments);
+ const el = $el[0];
+ _addTitleAndAllowedAttributes(el, this.title, this.options);
+ this.containerEl = document.createElement('div');
+
+ if (this.imgEl) {
+ this.containerEl.appendChild(this.imgEl);
+ }
+
+ el.appendChild(this.containerEl);
+ return $el;
+ },
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ if (this.el.classList.contains('o_we_img_animate')) {
+ const buildImgExtensionSwitcher = (from, to) => {
+ const regex = new RegExp(`${from}$`, 'i');
+ return ev => {
+ const img = ev.currentTarget.getElementsByTagName("img")[0];
+ img.src = img.src.replace(regex, to);
+ };
+ };
+ this.$el.on('mouseenter.img_animate', buildImgExtensionSwitcher('png', 'gif'));
+ this.$el.on('mouseleave.img_animate', buildImgExtensionSwitcher('gif', 'png'));
+ }
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ // Check if $el exists in case the widget is destroyed before it has
+ // been fully initialized.
+ // TODO there is probably better to do. This case was found only in
+ // tours, where the editor is left before the widget icon is loaded.
+ if (this.$el) {
+ this.$el.off('.img_animate');
+ }
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Closes the widget (only meaningful for widgets that can be closed).
+ */
+ close: function () {
+ if (!this.el) {
+ // In case the method is called while the widget is not fully
+ // initialized yet. No need to prevent that case: asking a non
+ // initialized widget to close itself should just not be a problem
+ // and just be ignored.
+ return;
+ }
+ this.trigger_up('user_value_widget_closing');
+ this.el.classList.remove('o_we_widget_opened');
+ this._userValueWidgets.forEach(widget => widget.close());
+ },
+ /**
+ * Simulates the correct event on the element to make it active.
+ */
+ enable() {
+ this.$el.click();
+ },
+ /**
+ * @param {string} name
+ * @returns {UserValueWidget|null}
+ */
+ findWidget: function (name) {
+ for (const widget of this._userValueWidgets) {
+ if (widget.getName() === name) {
+ return widget;
+ }
+ const depWidget = widget.findWidget(name);
+ if (depWidget) {
+ return depWidget;
+ }
+ }
+ return null;
+ },
+ /**
+ * Returns the value that the widget would hold if it was active, by default
+ * the internal value it holds.
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getActiveValue: function (methodName) {
+ return this._value;
+ },
+ /**
+ * Returns the default value the widget holds when inactive, by default the
+ * first "possible value".
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getDefaultValue: function (methodName) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ return possibleValues && possibleValues[0] || '';
+ },
+ /**
+ * @returns {string[]}
+ */
+ getDependencies: function () {
+ return this._dependencies;
+ },
+ /**
+ * Returns the names of the option methods associated to the widget. Those
+ * are loaded with @see loadMethodsData.
+ *
+ * @returns {string[]}
+ */
+ getMethodsNames: function () {
+ return this._methodsNames;
+ },
+ /**
+ * Returns the option parameters associated to the widget (for a given
+ * method name or not). Most are loaded with @see loadMethodsData.
+ *
+ * @param {string} [methodName]
+ * @returns {Object}
+ */
+ getMethodsParams: function (methodName) {
+ const params = _.extend({}, this._methodsParams);
+ if (methodName) {
+ params.possibleValues = params.optionsPossibleValues[methodName] || [];
+ params.activeValue = this.getActiveValue(methodName);
+ params.defaultValue = this.getDefaultValue(methodName);
+ }
+ return params;
+ },
+ /**
+ * @returns {string} empty string if no name is used by the widget
+ */
+ getName: function () {
+ return this._methodsParams.name || '';
+ },
+ /**
+ * Returns the user value that the widget currently holds. The value is a
+ * string, this is the value that will be received in the option methods
+ * of SnippetOptionWidget instances.
+ *
+ * @param {string} [methodName]
+ * @returns {string}
+ */
+ getValue: function (methodName) {
+ const isActive = this.isActive();
+ if (!methodName || !this._methodsNames.includes(methodName)) {
+ return isActive ? 'true' : '';
+ }
+ if (isActive) {
+ return this.getActiveValue(methodName);
+ }
+ return this.getDefaultValue(methodName);
+ },
+ /**
+ * Returns whether or not the widget is active (holds a value).
+ *
+ * @returns {boolean}
+ */
+ isActive: function () {
+ return this._value && this._value !== NULL_ID;
+ },
+ /**
+ * Indicates if the widget can contain sub user value widgets or not.
+ *
+ * @returns {boolean}
+ */
+ isContainer: function () {
+ return false;
+ },
+ /**
+ * Indicates if the widget is being previewed or not: the user is
+ * manipulating it. Base case: if an internal <input/> element is focused.
+ *
+ * @returns {boolean}
+ */
+ isPreviewed: function () {
+ const focusEl = document.activeElement;
+ if (focusEl && focusEl.tagName === 'INPUT'
+ && (this.el === focusEl || this.el.contains(focusEl))) {
+ return true;
+ }
+ return this.el.classList.contains('o_we_preview');
+ },
+ /**
+ * Loads option method names and option method parameters.
+ *
+ * @param {string[]} validMethodNames
+ * @param {Object} extraParams
+ */
+ loadMethodsData: function (validMethodNames, extraParams) {
+ this._methodsNames = [];
+ this._methodsParams = _.extend({}, extraParams);
+ this._methodsParams.optionsPossibleValues = {};
+ this._dependencies = [];
+ this._triggerWidgetsNames = [];
+ this._triggerWidgetsValues = [];
+
+ for (const key in this.el.dataset) {
+ const dataValue = this.el.dataset[key].trim();
+
+ if (key === 'dependencies') {
+ this._dependencies.push(...dataValue.split(/\s*,\s*/g));
+ } else if (key === 'trigger') {
+ this._triggerWidgetsNames.push(...dataValue.split(/\s*,\s*/g));
+ } else if (key === 'triggerValue') {
+ this._triggerWidgetsValues.push(...dataValue.split(/\s*,\s*/g));
+ } else if (validMethodNames.includes(key)) {
+ this._methodsNames.push(key);
+ this._methodsParams.optionsPossibleValues[key] = dataValue.split(/\s*\|\s*/g);
+ } else {
+ this._methodsParams[key] = dataValue;
+ }
+ }
+ this._userValueWidgets.forEach(widget => {
+ const inheritedParams = _.extend({}, this._methodsParams);
+ inheritedParams.optionsPossibleValues = null;
+ widget.loadMethodsData(validMethodNames, inheritedParams);
+ const subMethodsNames = widget.getMethodsNames();
+ const subMethodsParams = widget.getMethodsParams();
+
+ for (const methodName of subMethodsNames) {
+ if (!this._methodsNames.includes(methodName)) {
+ this._methodsNames.push(methodName);
+ this._methodsParams.optionsPossibleValues[methodName] = [];
+ }
+ for (const subPossibleValue of subMethodsParams.optionsPossibleValues[methodName]) {
+ this._methodsParams.optionsPossibleValues[methodName].push(subPossibleValue);
+ }
+ }
+ });
+ for (const methodName of this._methodsNames) {
+ const arr = this._methodsParams.optionsPossibleValues[methodName];
+ const uniqArr = arr.filter((v, i, arr) => i === arr.indexOf(v));
+ this._methodsParams.optionsPossibleValues[methodName] = uniqArr;
+ }
+ },
+ /**
+ * @param {boolean} [previewMode=false]
+ * @param {boolean} [isSimulatedEvent=false]
+ */
+ notifyValueChange: function (previewMode, isSimulatedEvent) {
+ // If the widget has no associated method, it should not notify user
+ // value changes
+ if (!this._methodsNames.length) {
+ return;
+ }
+
+ // In the case we notify a change update, force a preview update if it
+ // was not already previewed
+ const isPreviewed = this.isPreviewed();
+ if (!previewMode && !isPreviewed) {
+ this.notifyValueChange(true);
+ }
+
+ const data = {
+ previewMode: previewMode || false,
+ isSimulatedEvent: !!isSimulatedEvent,
+ };
+ // TODO improve this. The preview state has to be updated only when the
+ // actual option _select is gonna be called... but this is delayed by a
+ // mutex. So, during test tours, we would notify both 'preview' and
+ // 'reset' before the 'preview' handling is done: and so the widget
+ // would not be considered in preview during that 'preview' handling.
+ if (previewMode === true || previewMode === false) {
+ // Note: the widgets need to be considered in preview mode during
+ // non-preview handling (a previewed checkbox is considered having
+ // an inverted state)... but if, for example, a modal opens before
+ // handling that non-preview, a 'reset' will be thrown thus removing
+ // the preview class. So we force it in non-preview too.
+ data.prepare = () => this.el.classList.add('o_we_preview');
+ } else if (previewMode === 'reset') {
+ data.prepare = () => this.el.classList.remove('o_we_preview');
+ }
+
+ this.trigger_up('user_value_update', data);
+ },
+ /**
+ * Opens the widget (only meaningful for widgets that can be opened).
+ */
+ open() {
+ this.trigger_up('user_value_widget_opening');
+ this.el.classList.add('o_we_widget_opened');
+ },
+ /**
+ * Adds the given widget to the known list of user value sub-widgets (useful
+ * for container widgets).
+ *
+ * @param {UserValueWidget} widget
+ */
+ registerSubWidget: function (widget) {
+ this._userValueWidgets.push(widget);
+ },
+ /**
+ * Sets the user value that the widget should currently hold, for the
+ * given method name.
+ *
+ * Note: a widget typically only holds one value for the only method it
+ * supports. However, widgets can have several methods; in that case, the
+ * value is typically received for a first method and receiving the value
+ * for other ones should not affect the widget (otherwise, it means the
+ * methods are conflicting with each other).
+ *
+ * @param {string} value
+ * @param {string} [methodName]
+ */
+ async setValue(value, methodName) {
+ this._value = value;
+ this.el.classList.remove('o_we_preview');
+ },
+ /**
+ * @param {boolean} show
+ */
+ toggleVisibility: function (show) {
+ this.el.classList.toggle('d-none', !show);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent|Event}
+ * @returns {boolean}
+ */
+ _handleNotifierEvent: function (ev) {
+ if (!ev) {
+ return true;
+ }
+ if (ev._seen) {
+ return false;
+ }
+ ev._seen = true;
+ if (ev.preventDefault) {
+ ev.preventDefault();
+ }
+ return true;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * change.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValueChange: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange(false);
+ }
+ },
+ /**
+ * Allows container widgets to add additional data if needed.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onUserValueNotification: function (ev) {
+ ev.data.widget = this;
+
+ if (!ev.data.triggerWidgetsNames) {
+ ev.data.triggerWidgetsNames = [];
+ }
+ ev.data.triggerWidgetsNames.push(...this._triggerWidgetsNames);
+
+ if (!ev.data.triggerWidgetsValues) {
+ ev.data.triggerWidgetsValues = [];
+ }
+ ev.data.triggerWidgetsValues.push(...this._triggerWidgetsValues);
+ },
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * preview.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValuePreview: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange(true);
+ }
+ },
+ /**
+ * Should be called when an user event on the widget indicates a value
+ * reset.
+ *
+ * @private
+ * @param {OdooEvent|Event} [ev]
+ */
+ _onUserValueReset: function (ev) {
+ if (this._handleNotifierEvent(ev)) {
+ this.notifyValueChange('reset');
+ }
+ },
+});
+
+const ButtonUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-button',
+ events: {
+ 'click': '_onButtonClick',
+ 'click [role="button"]': '_onInnerButtonClick',
+ 'mouseenter': '_onUserValuePreview',
+ 'mouseleave': '_onUserValueReset',
+ },
+
+ /**
+ * @override
+ */
+ start: function (parent, title, options) {
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.containerEl.appendChild(node));
+ }
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getActiveValue: function (methodName) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ return possibleValues && possibleValues[possibleValues.length - 1] || '';
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ return (this.isPreviewed() !== this.el.classList.contains('active'));
+ },
+ /**
+ * @override
+ */
+ loadMethodsData: function (validMethodNames) {
+ this._super.apply(this, arguments);
+ for (const methodName of this._methodsNames) {
+ const possibleValues = this._methodsParams.optionsPossibleValues[methodName];
+ if (possibleValues.length <= 1) {
+ possibleValues.unshift('');
+ }
+ }
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ await this._super(...arguments);
+ let active = !!value;
+ if (methodName) {
+ if (!this._methodsNames.includes(methodName)) {
+ return;
+ }
+ active = (this.getActiveValue(methodName) === value);
+ }
+ this.el.classList.toggle('active', active);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onButtonClick: function (ev) {
+ if (!ev._innerButtonClicked) {
+ this._onUserValueChange(ev);
+ }
+ },
+ /**
+ * @private
+ */
+ _onInnerButtonClick: function (ev) {
+ // Cannot just stop propagation as the click needs to be propagated to
+ // potential parent widgets for event delegation on those inner buttons.
+ ev._innerButtonClicked = true;
+ },
+});
+
+const CheckboxUserValueWidget = ButtonUserValueWidget.extend({
+ className: (ButtonUserValueWidget.prototype.className || '') + ' o_we_checkbox_wrapper',
+
+ /**
+ * @override
+ */
+ start: function () {
+ const checkboxEl = document.createElement('we-checkbox');
+ this.containerEl.appendChild(checkboxEl);
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ enable() {
+ this.$('we-checkbox').click();
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onButtonClick(ev) {
+ if (!ev.target.closest('we-title, we-checkbox')) {
+ // Only consider clicks on the label and the checkbox control itself
+ return;
+ }
+ return this._super(...arguments);
+ },
+});
+
+const BaseSelectionUserValueWidget = UserValueWidget.extend({
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ this.menuEl = document.createElement('we-selection-items');
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.menuEl.appendChild(node));
+ }
+ this.containerEl.appendChild(this.menuEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getMethodsParams(methodName) {
+ const params = this._super(...arguments);
+ const activeWidget = this._getActiveSubWidget();
+ if (!activeWidget) {
+ return params;
+ }
+ return Object.assign(activeWidget.getMethodsParams(...arguments), params);
+ },
+ /**
+ * @override
+ */
+ getValue(methodName) {
+ const activeWidget = this._getActiveSubWidget();
+ if (activeWidget) {
+ return activeWidget.getActiveValue(methodName);
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ isContainer() {
+ return true;
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ const _super = this._super.bind(this);
+ for (const widget of this._userValueWidgets) {
+ await widget.setValue(NULL_ID, methodName);
+ }
+ for (const widget of [...this._userValueWidgets].reverse()) {
+ await widget.setValue(value, methodName);
+ if (widget.isActive()) {
+ // Only one select item can be true at a time, we consider the
+ // last one if multiple would be active.
+ return;
+ }
+ }
+ await _super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {UserValueWidget|undefined}
+ */
+ _getActiveSubWidget() {
+ const previewedWidget = this._userValueWidgets.find(widget => widget.isPreviewed());
+ if (previewedWidget) {
+ return previewedWidget;
+ }
+ return this._userValueWidgets.find(widget => widget.isActive());
+ },
+});
+
+const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({
+ tagName: 'we-select',
+ events: {
+ 'click': '_onClick',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+
+ if (this.options && this.options.valueEl) {
+ this.containerEl.insertBefore(this.options.valueEl, this.menuEl);
+ }
+
+ this.menuTogglerEl = document.createElement('we-toggler');
+ this.icon = this.el.dataset.icon || false;
+ if (this.icon) {
+ this.el.classList.add('o_we_icon_select');
+ const iconEl = document.createElement('i');
+ iconEl.classList.add('fa', 'fa-fw', this.icon);
+ this.menuTogglerEl.appendChild(iconEl);
+ }
+ this.containerEl.insertBefore(this.menuTogglerEl, this.menuEl);
+
+ const dropdownCaretEl = document.createElement('span');
+ dropdownCaretEl.classList.add('o_we_dropdown_caret');
+ this.containerEl.appendChild(dropdownCaretEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ close: function () {
+ this._super(...arguments);
+ if (this.menuTogglerEl) {
+ this.menuTogglerEl.classList.remove('active');
+ }
+ },
+ /**
+ * @override
+ */
+ isPreviewed: function () {
+ return this._super(...arguments) || this.menuTogglerEl.classList.contains('active');
+ },
+ /**
+ * @override
+ */
+ open() {
+ this._super(...arguments);
+ this.menuTogglerEl.classList.add('active');
+ },
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+
+ if (this.icon) {
+ return;
+ }
+
+ if (this.menuTogglerItemEl) {
+ this.menuTogglerItemEl.remove();
+ this.menuTogglerItemEl = null;
+ }
+
+ let textContent = '';
+ const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive());
+ if (activeWidget) {
+ const svgTag = activeWidget.el.querySelector('svg'); // useful to avoid searching text content in svg element
+ const value = (activeWidget.el.dataset.selectLabel || (!svgTag && activeWidget.el.textContent.trim()));
+ const imgSrc = activeWidget.el.dataset.img;
+ if (value) {
+ textContent = value;
+ } else if (imgSrc) {
+ this.menuTogglerItemEl = document.createElement('img');
+ this.menuTogglerItemEl.src = imgSrc;
+ } else {
+ const fakeImgEl = activeWidget.el.querySelector('.o_we_fake_img_item');
+ if (fakeImgEl) {
+ this.menuTogglerItemEl = fakeImgEl.cloneNode(true);
+ }
+ }
+ } else {
+ textContent = "/";
+ }
+
+ this.menuTogglerEl.textContent = textContent;
+ if (this.menuTogglerItemEl) {
+ this.menuTogglerEl.appendChild(this.menuTogglerItemEl);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _shouldIgnoreClick(ev) {
+ return !!ev.target.closest('[role="button"]');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the select is clicked anywhere -> open/close it.
+ *
+ * @private
+ */
+ _onClick: function (ev) {
+ if (this._shouldIgnoreClick(ev)) {
+ return;
+ }
+
+ if (!this.menuTogglerEl.classList.contains('active')) {
+ this.open();
+ } else {
+ this.close();
+ }
+ const activeButton = this._userValueWidgets.find(widget => widget.isActive());
+ if (activeButton) {
+ this.menuEl.scrollTop = activeButton.el.offsetTop - (this.menuEl.offsetHeight / 2);
+ }
+ },
+});
+
+const ButtonGroupUserValueWidget = BaseSelectionUserValueWidget.extend({
+ tagName: 'we-button-group',
+});
+
+const UnitUserValueWidget = UserValueWidget.extend({
+ /**
+ * @override
+ */
+ start: async function () {
+ const unit = this.el.dataset.unit || '';
+ this.el.dataset.unit = unit;
+ if (this.el.dataset.saveUnit === undefined) {
+ this.el.dataset.saveUnit = unit;
+ }
+
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getActiveValue: function (methodName) {
+ const activeValue = this._super(...arguments);
+
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return activeValue;
+ }
+
+ const defaultValue = this.getDefaultValue(methodName, false);
+
+ return activeValue.split(/\s+/g).map(v => {
+ const numValue = parseFloat(v);
+ if (isNaN(numValue)) {
+ return defaultValue;
+ } else {
+ const value = weUtils.convertNumericToUnit(numValue, params.unit, params.saveUnit, params.cssProperty, this.$target);
+ return `${this._floatToStr(value)}${params.saveUnit}`;
+ }
+ }).join(' ');
+ },
+ /**
+ * @override
+ * @param {boolean} [useInputUnit=false]
+ */
+ getDefaultValue: function (methodName, useInputUnit) {
+ const defaultValue = this._super(...arguments);
+
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return defaultValue;
+ }
+
+ const unit = useInputUnit ? params.unit : params.saveUnit;
+ const numValue = weUtils.convertValueToUnit(defaultValue || '0', unit, params.cssProperty, this.$target);
+ if (isNaN(numValue)) {
+ return defaultValue;
+ }
+ return `${this._floatToStr(numValue)}${unit}`;
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ const isSuperActive = this._super(...arguments);
+ const params = this._methodsParams;
+ if (!params.unit) {
+ return isSuperActive;
+ }
+ return isSuperActive && this._floatToStr(parseFloat(this._value)) !== '0';
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ const params = this._methodsParams;
+ if (params.unit) {
+ value = value.split(' ').map(v => {
+ const numValue = weUtils.convertValueToUnit(v, params.unit, params.cssProperty, this.$target);
+ if (isNaN(numValue)) {
+ return ''; // Something not supported
+ }
+ return this._floatToStr(numValue);
+ }).join(' ');
+ }
+ return this._super(value, methodName);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Converts a floating value to a string, rounded to 5 digits without zeros.
+ *
+ * @private
+ * @param {number} value
+ * @returns {string}
+ */
+ _floatToStr: function (value) {
+ return `${parseFloat(value.toFixed(5))}`;
+ },
+});
+
+const InputUserValueWidget = UnitUserValueWidget.extend({
+ tagName: 'we-input',
+ events: {
+ 'input input': '_onInputInput',
+ 'blur input': '_onInputBlur',
+ 'keydown input': '_onInputKeydown',
+ },
+
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+
+ const unit = this.el.dataset.unit;
+ this.inputEl = document.createElement('input');
+ this.inputEl.setAttribute('type', 'text');
+ this.inputEl.setAttribute('autocomplete', 'chrome-off');
+ this.inputEl.setAttribute('placeholder', this.el.getAttribute('placeholder') || '');
+ this.inputEl.classList.toggle('text-left', !unit);
+ this.inputEl.classList.toggle('text-right', !!unit);
+ this.containerEl.appendChild(this.inputEl);
+
+ var unitEl = document.createElement('span');
+ unitEl.textContent = unit;
+ this.containerEl.appendChild(unitEl);
+ if (unit.length > 3) {
+ this.el.classList.add('o_we_large_input');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ this.inputEl.value = this._value;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputInput: function (ev) {
+ this._value = this.inputEl.value;
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputBlur: function (ev) {
+ // Sometimes, an input is focusout for internal reason (like an undo
+ // recording) then focused again manually in the same JS stack
+ // execution. In that case, the blur should not trigger an option
+ // selection as the user did not leave the input. We thus defer the blur
+ // handling to then check that the target is indeed still blurred before
+ // executing the actual option selection.
+ setTimeout(() => {
+ if (ev.currentTarget === document.activeElement) {
+ return;
+ }
+ this._onUserValueChange(ev);
+ });
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onInputKeydown: function (ev) {
+ switch (ev.which) {
+ case $.ui.keyCode.ENTER: {
+ this._onUserValueChange(ev);
+ break;
+ }
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN: {
+ const input = ev.currentTarget;
+ const params = this._methodsParams;
+ if (!params.unit && !params.step) {
+ break;
+ }
+ let value = parseFloat(input.value || input.placeholder);
+ if (isNaN(value)) {
+ value = 0.0;
+ }
+ let step = parseFloat(params.step);
+ if (isNaN(step)) {
+ step = 1.0;
+ }
+ value += (ev.which === $.ui.keyCode.UP ? step : -step);
+ input.value = this._floatToStr(value);
+ $(input).trigger('input');
+ break;
+ }
+ }
+ },
+});
+
+const MultiUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-multi',
+
+ /**
+ * @override
+ */
+ start: function () {
+ if (this.options && this.options.childNodes) {
+ this.options.childNodes.forEach(node => this.containerEl.appendChild(node));
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ getValue: function (methodName) {
+ const value = this._userValueWidgets.map(widget => {
+ return widget.getValue(methodName);
+ }).join(' ').trim();
+
+ return value || this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ isContainer: function () {
+ return true;
+ },
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ let values = value.split(/\s*\|\s*/g);
+ if (values.length === 1) {
+ values = value.split(/\s+/g);
+ }
+ for (let i = 0; i < this._userValueWidgets.length - 1; i++) {
+ await this._userValueWidgets[i].setValue(values.shift() || '', methodName);
+ }
+ await this._userValueWidgets[this._userValueWidgets.length - 1].setValue(values.join(' '), methodName);
+ },
+});
+
+const ColorpickerUserValueWidget = SelectUserValueWidget.extend({
+ className: (SelectUserValueWidget.prototype.className || '') + ' o_we_so_color_palette',
+ custom_events: _.extend({}, SelectUserValueWidget.prototype.custom_events, {
+ 'custom_color_picked': '_onCustomColorPicked',
+ 'color_picked': '_onColorPicked',
+ 'color_hover': '_onColorHovered',
+ 'color_leave': '_onColorLeft',
+ 'enter_key_color_colorpicker': '_onEnterKey'
+ }),
+
+ /**
+ * @override
+ */
+ start: async function () {
+ const _super = this._super.bind(this);
+ const args = arguments;
+
+ if (this.options.dataAttributes.lazyPalette === 'true') {
+ // TODO review in master, this was done in stable to keep the speed
+ // fix as stable as possible (to have a reference to a widget even
+ // if not a colorPalette widget).
+ this.colorPalette = new Widget(this);
+ this.colorPalette.getColorNames = () => [];
+ await this.colorPalette.appendTo(document.createDocumentFragment());
+ } else {
+ await this._renderColorPalette();
+ }
+
+ // Build the select element with a custom span to hold the color preview
+ this.colorPreviewEl = document.createElement('span');
+ this.colorPreviewEl.classList.add('o_we_color_preview');
+ this.options.childNodes = [this.colorPalette.el];
+ this.options.valueEl = this.colorPreviewEl;
+
+ return _super(...args);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ open: function () {
+ if (this.colorPalette.setSelectedColor) {
+ this.colorPalette.setSelectedColor(this._value);
+ } else {
+ // TODO review in master, this does async stuff. Maybe the open
+ // method should now be async. This is not really robust as the
+ // colorPalette can be used without it to be fully rendered but
+ // the use of the saved promise where we can should mitigate that
+ // issue.
+ this._colorPaletteRenderPromise = this._renderColorPalette();
+ }
+ this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ close: function () {
+ this._super(...arguments);
+ if (this._customColorValue && this._customColorValue !== this._value) {
+ this._value = this._customColorValue;
+ this._customColorValue = false;
+ this._onUserValueChange();
+ }
+ },
+ /**
+ * @override
+ */
+ getMethodsParams: function () {
+ return _.extend(this._super(...arguments), {
+ colorNames: this.colorPalette.getColorNames(),
+ });
+ },
+ /**
+ * @override
+ */
+ getValue: function (methodName) {
+ if (typeof this._previewColor === 'string') {
+ return this._previewColor;
+ }
+ if (typeof this._customColorValue === 'string') {
+ return this._customColorValue;
+ }
+ let value = this._super(...arguments);
+ if (value) {
+ const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor');
+ const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible');
+ if ((useCssColor || cssCompatible) && !ColorpickerWidget.isCSSColor(value)) {
+ if (useCssColor) {
+ value = weUtils.getCSSVariableValue(value);
+ } else {
+ value = `var(--${value})`;
+ }
+ }
+ }
+ return value;
+ },
+ /**
+ * @override
+ */
+ isContainer: function () {
+ return false;
+ },
+ /**
+ * @override
+ */
+ isActive: function () {
+ return !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)');
+ },
+ /**
+ * Updates the color preview + re-render the whole color palette widget.
+ *
+ * @override
+ */
+ async setValue(color) {
+ await this._super(...arguments);
+
+ await this._colorPaletteRenderPromise;
+
+ const classes = weUtils.computeColorClasses(this.colorPalette.getColorNames());
+ this.colorPreviewEl.classList.remove(...classes);
+ this.colorPreviewEl.style.removeProperty('background-color');
+
+ if (this._value) {
+ if (ColorpickerWidget.isCSSColor(this._value)) {
+ this.colorPreviewEl.style.backgroundColor = this._value;
+ } else if (weUtils.isColorCombinationName(this._value)) {
+ this.colorPreviewEl.classList.add('o_cc', `o_cc${this._value}`);
+ } else {
+ this.colorPreviewEl.classList.add(`bg-${this._value}`);
+ }
+ }
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _renderColorPalette: function () {
+ const options = {
+ selectedColor: this._value,
+ };
+ if (this.options.dataAttributes.excluded) {
+ options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(',');
+ }
+ if (this.options.dataAttributes.withCombinations) {
+ options.withCombinations = !!this.options.dataAttributes.withCombinations;
+ }
+ const oldColorPalette = this.colorPalette;
+ this.colorPalette = new ColorPaletteWidget(this, options);
+ if (oldColorPalette) {
+ return this.colorPalette.insertAfter(oldColorPalette.el).then(() => {
+ oldColorPalette.destroy();
+ });
+ }
+ return this.colorPalette.appendTo(document.createDocumentFragment());
+ },
+ /**
+ * @override
+ */
+ _shouldIgnoreClick(ev) {
+ return ev.originalEvent.__isColorpickerClick || this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a custom color is selected -> preview the color
+ * and set the current value. Update of this value on close
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onCustomColorPicked: function (ev) {
+ this._customColorValue = ev.data.color;
+ },
+ /**
+ * Called when a color button is clicked -> confirms the preview.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPicked: function (ev) {
+ this._previewColor = false;
+ this._customColorValue = false;
+ this._value = ev.data.color;
+ this._onUserValueChange(ev);
+ },
+ /**
+ * Called when a color button is entered -> previews the background color.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorHovered: function (ev) {
+ this._previewColor = ev.data.color;
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * Called when a color button is left -> cancels the preview.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorLeft: function (ev) {
+ this._previewColor = false;
+ this._onUserValueReset(ev);
+ },
+ /**
+ * @private
+ */
+ _onEnterKey: function () {
+ this.close();
+ },
+});
+
+const MediapickerUserValueWidget = UserValueWidget.extend({
+ tagName: 'we-button',
+ events: {
+ 'click': '_onEditMedia',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ const iconEl = document.createElement('i');
+ if (this.options.dataAttributes.buttonStyle) {
+ iconEl.classList.add('fa', 'fa-fw', 'fa-camera');
+ } else {
+ iconEl.classList.add('fa', 'fa-fw', 'fa-refresh', 'mr-1');
+ this.el.classList.add('o_we_no_toggle');
+ this.containerEl.textContent = _t("Replace media");
+ }
+ $(this.containerEl).prepend(iconEl);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Creates and opens a media dialog to edit a given element's media.
+ *
+ * @private
+ * @param {HTMLElement} el the element whose media should be edited
+ * @param {boolean} [images] whether images should be available
+ * default: false
+ * @param {boolean} [videos] whether videos should be available
+ * default: false
+ */
+ _openDialog(el, {images = false, videos = false}) {
+ el.src = this._value;
+ const $editable = this.$target.closest('.o_editable');
+ const mediaDialog = new weWidgets.MediaDialog(this, {
+ noImages: !images,
+ noVideos: !videos,
+ noIcons: true,
+ noDocuments: true,
+ isForBgVideo: true,
+ 'res_model': $editable.data('oe-model'),
+ 'res_id': $editable.data('oe-id'),
+ }, el).open();
+ return mediaDialog;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ this.el.classList.toggle('active', this.isActive());
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the edit button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onEditMedia: function (ev) {},
+});
+
+const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onEditMedia(ev) {
+ // Need a dummy element for the media dialog to modify.
+ const dummyEl = document.createElement('img');
+ const dialog = this._openDialog(dummyEl, {images: true});
+ dialog.on('save', this, data => {
+ // Accessing the value directly through dummyEl.src converts the url to absolute,
+ // using getAttribute allows us to keep the url as it was inserted in the DOM
+ // which can be useful to compare it to values stored in db.
+ this._value = dummyEl.getAttribute('src');
+ this._onUserValueChange();
+ });
+ },
+});
+
+const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onEditMedia(ev) {
+ // Need a dummy element for the media dialog to modify.
+ const dummyEl = document.createElement('iframe');
+ const dialog = this._openDialog(dummyEl, {videos: true});
+ dialog.on('save', this, data => {
+ this._value = data.bgVideoSrc;
+ this._onUserValueChange();
+ });
+ },
+});
+
+const DatetimePickerUserValueWidget = InputUserValueWidget.extend({
+ events: { // Explicitely not consider all InputUserValueWidget events
+ 'blur input': '_onInputBlur',
+ 'change.datetimepicker': '_onDateTimePickerChange',
+ 'error.datetimepicker': '_onDateTimePickerError',
+ },
+
+ /**
+ * @override
+ */
+ init: function () {
+ this._super(...arguments);
+ this._value = moment().unix().toString();
+ this.__libInput = 0;
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+
+ const datetimePickerId = _.uniqueId('datetimepicker');
+ this.el.classList.add('o_we_large_input');
+ this.inputEl.classList.add('datetimepicker-input', 'mx-0', 'text-left');
+ this.inputEl.setAttribute('id', datetimePickerId);
+ this.inputEl.setAttribute('data-target', '#' + datetimePickerId);
+
+ const datepickersOptions = {
+ minDate: moment({ y: 1000 }),
+ maxDate: moment().add(200, 'y'),
+ calendarWeeks: true,
+ defaultDate: moment().format(),
+ icons: {
+ close: 'fa fa-check primary',
+ },
+ locale: moment.locale(),
+ format: time.getLangDatetimeFormat(),
+ sideBySide: true,
+ buttons: {
+ showClose: true,
+ showToday: true,
+ },
+ widgetParent: 'body',
+
+ // Open the datetimepicker on focus not on click. This allows to
+ // take care of a bug which is due to the summernote editor:
+ // sometimes, the datetimepicker loses the focus then get it back
+ // in the same execution flow. This was making the datepicker close
+ // for no apparent reason. Now, it only closes then reopens directly
+ // without it be possible to notice.
+ allowInputToggle: true,
+ };
+ this.__libInput++;
+ const $input = $(this.inputEl);
+ $input.datetimepicker(datepickersOptions);
+ this.__libInput--;
+
+ // Monkey-patch the library option to add custom classes on the pickers
+ const libObject = $input.data('datetimepicker');
+ const oldFunc = libObject._getTemplate;
+ libObject._getTemplate = function () {
+ const $template = oldFunc.call(this, ...arguments);
+ $template.addClass('o_we_no_overlay o_we_datetimepicker');
+ return $template;
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ isPreviewed: function () {
+ return this._super(...arguments) || !!$(this.inputEl).data('datetimepicker').widget;
+ },
+ /**
+ * @override
+ */
+ async setValue() {
+ await this._super(...arguments);
+ let momentObj = moment.unix(this._value);
+ if (!momentObj.isValid()) {
+ momentObj = moment();
+ }
+ this.__libInput++;
+ $(this.inputEl).datetimepicker('date', momentObj);
+ this.__libInput--;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onDateTimePickerChange: function (ev) {
+ if (this.__libInput > 0) {
+ return;
+ }
+ if (!ev.date || !ev.date.isValid()) {
+ return;
+ }
+ this._value = ev.date.unix().toString();
+ this._onUserValuePreview(ev);
+ },
+ /**
+ * Prevents crash manager to throw CORS error. Note that library already
+ * clears the wrong date format.
+ */
+ _onDateTimePickerError: function (ev) {
+ ev.stopPropagation();
+ },
+});
+
+const RangeUserValueWidget = UnitUserValueWidget.extend({
+ tagName: 'we-range',
+ events: {
+ 'change input': '_onInputChange',
+ },
+
+ /**
+ * @override
+ */
+ async start() {
+ await this._super(...arguments);
+ this.input = document.createElement('input');
+ this.input.type = "range";
+ let min = this.el.dataset.min && parseFloat(this.el.dataset.min) || 0;
+ let max = this.el.dataset.max && parseFloat(this.el.dataset.max) || 100;
+ const step = this.el.dataset.step && parseFloat(this.el.dataset.step) || 1;
+ if (min > max) {
+ [min, max] = [max, min];
+ this.input.classList.add('o_we_inverted_range');
+ }
+ this.input.setAttribute('min', min);
+ this.input.setAttribute('max', max);
+ this.input.setAttribute('step', step);
+ this.containerEl.appendChild(this.input);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async setValue(value, methodName) {
+ await this._super(...arguments);
+ this.input.value = this._value;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onInputChange(ev) {
+ this._value = ev.target.value;
+ this._onUserValueChange(ev);
+ },
+});
+
+const SelectPagerUserValueWidget = SelectUserValueWidget.extend({
+ className: (SelectUserValueWidget.prototype.className || '') + ' o_we_select_pager',
+ events: Object.assign({}, SelectUserValueWidget.prototype.events, {
+ 'click .o_we_pager_next, .o_we_pager_prev': '_onPageChange',
+ }),
+
+ /**
+ * @override
+ */
+ async start() {
+ const _super = this._super.bind(this);
+ this.pages = this.options.childNodes.filter(node => node.matches && node.matches('we-select-page'));
+ this.numPages = this.pages.length;
+
+ const prev = document.createElement('i');
+ prev.classList.add('o_we_pager_prev', 'fa', 'fa-chevron-left');
+
+ this.pageNum = document.createElement('span');
+ this.currentPage = 0;
+
+ const next = document.createElement('i');
+ next.classList.add('o_we_pager_next', 'fa', 'fa-chevron-right');
+
+ const pagerControls = document.createElement('div');
+ pagerControls.classList.add('o_we_pager_controls');
+ pagerControls.appendChild(prev);
+ pagerControls.appendChild(this.pageNum);
+ pagerControls.appendChild(next);
+
+ this.pageName = document.createElement('b');
+ const pagerHeader = document.createElement('div');
+ pagerHeader.classList.add('o_we_pager_header');
+ pagerHeader.appendChild(this.pageName);
+ pagerHeader.appendChild(pagerControls);
+
+ await _super(...arguments);
+ this.menuEl.classList.add('o_we_has_pager');
+ $(this.menuEl).prepend(pagerHeader);
+ this._updatePage();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _shouldIgnoreClick(ev) {
+ return !!ev.target.closest('.o_we_pager_header') || this._super(...arguments);
+ },
+ /**
+ * Updates the pager's page number display.
+ *
+ * @private
+ */
+ _updatePage() {
+ this.pages.forEach((page, i) => page.classList.toggle('active', i === this.currentPage));
+ this.pageNum.textContent = `${this.currentPage + 1}/${this.numPages}`;
+ const activePage = this.pages.find((page, i) => i === this.currentPage);
+ this.pageName.textContent = activePage.getAttribute('string');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Goes to the previous/next page with wrap-around.
+ *
+ * @private
+ */
+ _onPageChange(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const delta = ev.target.matches('.o_we_pager_next') ? 1 : -1;
+ this.currentPage = (this.currentPage + this.numPages + delta) % this.numPages;
+ this._updatePage();
+ },
+ /**
+ * @override
+ */
+ _onClick(ev) {
+ const activeButton = this._getActiveSubWidget();
+ if (activeButton) {
+ const currentPage = this.pages.indexOf(activeButton.el.closest('we-select-page'));
+ if (currentPage !== -1) {
+ this.currentPage = currentPage;
+ this._updatePage();
+ }
+ }
+ return this._super(...arguments);
+ },
+});
+
+const userValueWidgetsRegistry = {
+ 'we-button': ButtonUserValueWidget,
+ 'we-checkbox': CheckboxUserValueWidget,
+ 'we-select': SelectUserValueWidget,
+ 'we-button-group': ButtonGroupUserValueWidget,
+ 'we-input': InputUserValueWidget,
+ 'we-multi': MultiUserValueWidget,
+ 'we-colorpicker': ColorpickerUserValueWidget,
+ 'we-datetimepicker': DatetimePickerUserValueWidget,
+ 'we-imagepicker': ImagepickerUserValueWidget,
+ 'we-videopicker': VideopickerUserValueWidget,
+ 'we-range': RangeUserValueWidget,
+ 'we-select-pager': SelectPagerUserValueWidget,
+};
+
+/**
+ * Handles a set of options for one snippet. The registry returned by this
+ * module contains the names of the specialized SnippetOptionWidget which can be
+ * referenced thanks to the data-js key in the web_editor options template.
+ */
+const SnippetOptionWidget = Widget.extend({
+ tagName: 'we-customizeblock-option',
+ events: {
+ 'click .o_we_collapse_toggler': '_onCollapseTogglerClick',
+ },
+ custom_events: {
+ 'user_value_update': '_onUserValueUpdate',
+ 'user_value_widget_critical': '_onUserValueWidgetCritical',
+ },
+ /**
+ * Indicates if the option should be displayed in the button group at the
+ * top of the options panel, next to the clone/remove button.
+ *
+ * @type {boolean}
+ */
+ isTopOption: false,
+ /**
+ * Forces the target to not be possible to remove.
+ *
+ * @type {boolean}
+ */
+ forceNoDeleteButton: false,
+
+ /**
+ * The option `$el` is supposed to be the associated DOM UI element.
+ * The option controls another DOM element: the snippet it
+ * customizes, which can be found at `$target`. Access to the whole edition
+ * overlay is possible with `$overlay` (this is not recommended though).
+ *
+ * @constructor
+ */
+ init: function (parent, $uiElements, $target, $overlay, data, options) {
+ this._super.apply(this, arguments);
+
+ this.$originalUIElements = $uiElements;
+
+ this.$target = $target;
+ this.$overlay = $overlay;
+ this.data = data;
+ this.options = options;
+
+ this.className = 'snippet-option-' + this.data.optionName;
+
+ this.ownerDocument = this.$target[0].ownerDocument;
+
+ this._userValueWidgets = [];
+ this._actionQueues = new Map();
+ },
+ /**
+ * @override
+ */
+ willStart: async function () {
+ await this._super(...arguments);
+ return this._renderOriginalXML().then(uiFragment => {
+ this.uiFragment = uiFragment;
+ });
+ },
+ /**
+ * @override
+ */
+ renderElement: function () {
+ this._super(...arguments);
+ this.el.appendChild(this.uiFragment);
+ this.uiFragment = null;
+ },
+ /**
+ * Called when the parent edition overlay is covering the associated snippet
+ * (the first time, this follows the call to the @see start method).
+ *
+ * @abstract
+ */
+ onFocus: function () {},
+ /**
+ * Called when the parent edition overlay is covering the associated snippet
+ * for the first time, when it is a new snippet dropped from the d&d snippet
+ * menu. Note: this is called after the start and onFocus methods.
+ *
+ * @abstract
+ */
+ onBuilt: function () {},
+ /**
+ * Called when the parent edition overlay is removed from the associated
+ * snippet (another snippet enters edition for example).
+ *
+ * @abstract
+ */
+ onBlur: function () {},
+ /**
+ * Called when the associated snippet is the result of the cloning of
+ * another snippet (so `this.$target` is a cloned element).
+ *
+ * @abstract
+ * @param {Object} options
+ * @param {boolean} options.isCurrent
+ * true if the associated snippet is a clone of the main element that
+ * was cloned (so not a clone of a child of this main element that
+ * was cloned)
+ */
+ onClone: function (options) {},
+ /**
+ * Called when the associated snippet is moved to another DOM location.
+ *
+ * @abstract
+ */
+ onMove: function () {},
+ /**
+ * Called when the associated snippet is about to be removed from the DOM.
+ *
+ * @abstract
+ */
+ onRemove: function () {},
+ /**
+ * Called when the target is shown, only meaningful if the target was hidden
+ * at some point (typically used for 'invisible' snippets).
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ onTargetShow: async function () {},
+ /**
+ * Called when the target is hidden (typically used for 'invisible'
+ * snippets).
+ *
+ * @abstract
+ * @returns {Promise|undefined}
+ */
+ onTargetHide: async function () {},
+ /**
+ * Called when the template which contains the associated snippet is about
+ * to be saved.
+ *
+ * @abstract
+ * @return {Promise|undefined}
+ */
+ cleanForSave: async function () {},
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Default option method which allows to select one and only one class in
+ * the option classes set and set it on the associated snippet. The common
+ * case is having a select with each item having a `data-select-class`
+ * value allowing to choose the associated class, or simply an unique
+ * checkbox to allow toggling a unique class.
+ *
+ * @param {boolean|string} previewMode
+ * - truthy if the option is enabled for preview or if leaving it (in
+ * that second case, the value is 'reset')
+ * - false if the option should be activated for good
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectClass: function (previewMode, widgetValue, params) {
+ for (const classNames of params.possibleValues) {
+ if (classNames) {
+ this.$target[0].classList.remove(...classNames.trim().split(/\s+/g));
+ }
+ }
+ if (widgetValue) {
+ this.$target[0].classList.add(...widgetValue.trim().split(/\s+/g));
+ }
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as a data attribute. The name of the data attribute is
+ * given by the attributeName parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectDataAttribute: function (previewMode, widgetValue, params) {
+ const value = this._selectAttributeHelper(widgetValue, params);
+ this.$target[0].dataset[params.attributeName] = value;
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as an attribute. The name of the attribute is
+ * given by the attributeName parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectAttribute: function (previewMode, widgetValue, params) {
+ const value = this._selectAttributeHelper(widgetValue, params);
+ this.$target[0].setAttribute(params.attributeName, value);
+ },
+ /**
+ * Default option method which allows to select a value and set it on the
+ * associated snippet as a css style. The name of the css property is
+ * given by the cssProperty parameter.
+ *
+ * @param {boolean} previewMode - @see this.selectClass
+ * @param {string} widgetValue
+ * @param {Object} params
+ * @returns {Promise|undefined}
+ */
+ selectStyle: function (previewMode, widgetValue, params) {
+ // Disable all transitions for the duration of the method as many
+ // comparisons will be done on the element to know if applying a
+ // property has an effect or not. Also, changing a css property via the
+ // editor should not show any transition as previews would not be done
+ // immediately, which is not good for the user experience.
+ this.$target[0].classList.add('o_we_force_no_transition');
+ const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition');
+
+ if (params.cssProperty === 'background-color') {
+ this.$target.trigger('background-color-event', previewMode);
+ }
+
+ const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty];
+ for (const cssProp of cssProps) {
+ // Always reset the inline style first to not put inline style on an
+ // element which already have this style through css stylesheets.
+ this.$target[0].style.setProperty(cssProp, '');
+ }
+ if (params.extraClass) {
+ this.$target.removeClass(params.extraClass);
+ }
+
+ // Only allow to use a color name as a className if we know about the
+ // other potential color names (to remove) and if we know about a prefix
+ // (otherwise we suppose that we should use the actual related color).
+ if (params.colorNames && params.colorPrefix) {
+ const classes = weUtils.computeColorClasses(params.colorNames, params.colorPrefix);
+ this.$target[0].classList.remove(...classes);
+
+ if (weUtils.isColorCombinationName(widgetValue)) {
+ // Those are the special color combinations classes. Just have
+ // to add it (and adding the potential extra class) then leave.
+ this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`, params.extraClass);
+ _restoreTransitions();
+ return;
+ }
+ if (params.colorNames.includes(widgetValue)) {
+ const originalCSSValue = window.getComputedStyle(this.$target[0])[cssProps[0]];
+ const className = params.colorPrefix + widgetValue;
+ this.$target[0].classList.add(className);
+ if (originalCSSValue !== window.getComputedStyle(this.$target[0])[cssProps[0]]) {
+ // If applying the class did indeed changed the css
+ // property we are editing, nothing more has to be done.
+ // (except adding the extra class)
+ this.$target.addClass(params.extraClass);
+ _restoreTransitions();
+ return;
+ }
+ // Otherwise, it means that class probably does not exist,
+ // we remove it and continue. Especially useful for some
+ // prefixes which only work with some color names but not all.
+ this.$target[0].classList.remove(className);
+ }
+ }
+
+ // At this point, the widget value is either a property/color name or
+ // an actual css property value. If it is a property/color name, we will
+ // apply a css variable as style value.
+ const htmlPropValue = weUtils.getCSSVariableValue(widgetValue);
+ if (htmlPropValue) {
+ widgetValue = `var(--${widgetValue})`;
+ }
+
+ // replacing ', ' by ',' to prevent attributes with internal space separators from being split:
+ // eg: "rgba(55, 12, 47, 1.9) 47px" should be split as ["rgba(55,12,47,1.9)", "47px"]
+ const values = widgetValue.replace(/,\s/g, ',').split(/\s+/g);
+ while (values.length < cssProps.length) {
+ switch (values.length) {
+ case 1:
+ case 2: {
+ values.push(values[0]);
+ break;
+ }
+ case 3: {
+ values.push(values[1]);
+ break;
+ }
+ default: {
+ values.push(values[values.length - 1]);
+ }
+ }
+ }
+
+ const styles = window.getComputedStyle(this.$target[0]);
+ let hasUserValue = false;
+ for (let i = cssProps.length - 1; i > 0; i--) {
+ hasUserValue = applyCSS.call(this, cssProps[i], values.pop(), styles) || hasUserValue;
+ }
+ hasUserValue = applyCSS.call(this, cssProps[0], values.join(' '), styles) || hasUserValue;
+
+ function applyCSS(cssProp, cssValue, styles) {
+ if (!weUtils.areCssValuesEqual(styles[cssProp], cssValue)) {
+ this.$target[0].style.setProperty(cssProp, cssValue, 'important');
+ return true;
+ }
+ return false;
+ }
+
+ if (params.extraClass) {
+ this.$target.toggleClass(params.extraClass, hasUserValue);
+ }
+
+ _restoreTransitions();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override the helper method to search inside the $target element instead
+ * of the UI item element.
+ *
+ * @override
+ */
+ $: function () {
+ return this.$target.find.apply(this.$target, arguments);
+ },
+ /**
+ * Closes all user value widgets.
+ */
+ closeWidgets: function () {
+ this._userValueWidgets.forEach(widget => widget.close());
+ },
+ /**
+ * @param {string} name
+ * @returns {UserValueWidget|null}
+ */
+ findWidget: function (name) {
+ for (const widget of this._userValueWidgets) {
+ if (widget.getName() === name) {
+ return widget;
+ }
+ const depWidget = widget.findWidget(name);
+ if (depWidget) {
+ return depWidget;
+ }
+ }
+ return null;
+ },
+ /**
+ * Sometimes, options may need to notify other options, even in parent
+ * editors. This can be done thanks to the 'option_update' event, which
+ * will then be handled by this function.
+ *
+ * @param {string} name - an identifier for a type of update
+ * @param {*} data
+ * @returns {Promise}
+ */
+ notify: function (name, data) {
+ if (name === 'target') {
+ this.setTarget(data);
+ }
+ },
+ /**
+ * Sometimes, an option is binded on an element but should in fact apply on
+ * another one. For example, elements which contain slides: we want all the
+ * per-slide options to be in the main menu of the whole snippet. This
+ * function allows to set the option's target.
+ *
+ * Note: the UI is not updated accordindly automatically.
+ *
+ * @param {jQuery} $target - the new target element
+ * @returns {Promise}
+ */
+ setTarget: function ($target) {
+ this.$target = $target;
+ },
+ /**
+ * Updates the UI. For widget update, @see _computeWidgetState.
+ *
+ * @param {boolean} [noVisibility=false]
+ * If true, only update widget values and their UI, not their visibility
+ * -> @see updateUIVisibility for toggling visibility only
+ * @returns {Promise}
+ */
+ updateUI: async function ({noVisibility} = {}) {
+ // For each widget, for each of their option method, notify to the
+ // widget the current value they should hold according to the $target's
+ // current state, related for that method.
+ const proms = this._userValueWidgets.map(async widget => {
+ // Update widget value (for each method)
+ const methodsNames = widget.getMethodsNames();
+ for (const methodName of methodsNames) {
+ const params = widget.getMethodsParams(methodName);
+
+ let obj = this;
+ if (params.applyTo) {
+ const $firstSubTarget = this.$(params.applyTo).eq(0);
+ if (!$firstSubTarget.length) {
+ continue;
+ }
+ obj = createPropertyProxy(this, '$target', $firstSubTarget);
+ }
+
+ const value = await this._computeWidgetState.call(obj, methodName, params);
+ if (value === undefined) {
+ continue;
+ }
+ const normalizedValue = this._normalizeWidgetValue(value);
+ await widget.setValue(normalizedValue, methodName);
+ }
+ });
+ await Promise.all(proms);
+
+ if (!noVisibility) {
+ await this.updateUIVisibility();
+ }
+ },
+ /**
+ * Updates the UI visibility - @see _computeVisibility. For widget update,
+ * @see _computeWidgetVisibility.
+ *
+ * @returns {Promise}
+ */
+ updateUIVisibility: async function () {
+ const proms = this._userValueWidgets.map(async widget => {
+ const params = widget.getMethodsParams();
+
+ let obj = this;
+ if (params.applyTo) {
+ const $firstSubTarget = this.$(params.applyTo).eq(0);
+ if (!$firstSubTarget.length) {
+ widget.toggleVisibility(false);
+ return;
+ }
+ obj = createPropertyProxy(this, '$target', $firstSubTarget);
+ }
+
+ // Make sure to check the visibility of all sub-widgets. For
+ // simplicity and efficiency, those will be checked with main
+ // widgets params.
+ const allSubWidgets = [widget];
+ let i = 0;
+ while (i < allSubWidgets.length) {
+ allSubWidgets.push(...allSubWidgets[i]._userValueWidgets);
+ i++;
+ }
+ const proms = allSubWidgets.map(async widget => {
+ const show = await this._computeWidgetVisibility.call(obj, widget.getName(), params);
+ if (!show) {
+ widget.toggleVisibility(false);
+ return;
+ }
+
+ const dependencies = widget.getDependencies();
+ const dependenciesData = [];
+ dependencies.forEach(depName => {
+ const toBeActive = (depName[0] !== '!');
+ if (!toBeActive) {
+ depName = depName.substr(1);
+ }
+
+ const widget = this._requestUserValueWidgets(depName)[0];
+ if (widget) {
+ dependenciesData.push({
+ widget: widget,
+ toBeActive: toBeActive,
+ });
+ }
+ });
+ const dependenciesOK = !dependenciesData.length || dependenciesData.some(depData => {
+ return (depData.widget.isActive() === depData.toBeActive);
+ });
+
+ widget.toggleVisibility(dependenciesOK);
+ });
+ return Promise.all(proms);
+ });
+
+ const showUI = await this._computeVisibility();
+ this.el.classList.toggle('d-none', !showUI);
+
+ await Promise.all(proms);
+
+ // Hide layouting elements which contains only hidden widgets
+ // TODO improve this, this is hackish to rely on DOM structure here.
+ // Layouting elements should be handled as widgets or other.
+ for (const el of this.$el.find('we-row')) {
+ el.classList.toggle('d-none', !$(el).find('> div > .o_we_user_value_widget').not('.d-none').length);
+ }
+ for (const el of this.$el.find('we-collapse')) {
+ const $el = $(el);
+ el.classList.toggle('d-none', $el.children().first().hasClass('d-none'));
+ const hasNoVisibleElInCollapseMenu = !$el.children().last().children().not('.d-none').length;
+ if (hasNoVisibleElInCollapseMenu) {
+ this._toggleCollapseEl(el, false);
+ }
+ el.querySelector('.o_we_collapse_toggler').classList.toggle('d-none', hasNoVisibleElInCollapseMenu);
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {UserValueWidget[]} widgets
+ * @returns {Promise<string>}
+ */
+ async _checkIfWidgetsUpdateNeedWarning(widgets) {
+ const messages = [];
+ for (const widget of widgets) {
+ const message = widget.getMethodsParams().warnMessage;
+ if (message) {
+ messages.push(message);
+ }
+ }
+ return messages.join(' ');
+ },
+ /**
+ * @private
+ * @param {UserValueWidget[]} widgets
+ * @returns {Promise<boolean|string>}
+ */
+ async _checkIfWidgetsUpdateNeedReload(widgets) {
+ return false;
+ },
+ /**
+ * @private
+ * @returns {Promise<boolean>|boolean}
+ */
+ _computeVisibility: async function () {
+ return true;
+ },
+ /**
+ * Returns the string value that should be hold by the widget which is
+ * related to the given method name.
+ *
+ * If the value is irrelevant for a method, it must return undefined.
+ *
+ * @private
+ * @param {string} methodName
+ * @param {Object} params
+ * @returns {Promise<string|undefined>|string|undefined}
+ */
+ _computeWidgetState: async function (methodName, params) {
+ switch (methodName) {
+ case 'selectClass': {
+ let maxNbClasses = 0;
+ let activeClassNames = '';
+ params.possibleValues.forEach(classNames => {
+ if (!classNames) {
+ return;
+ }
+ const classes = classNames.split(/\s+/g);
+ if (classes.length >= maxNbClasses
+ && classes.every(className => this.$target[0].classList.contains(className))) {
+ maxNbClasses = classes.length;
+ activeClassNames = classNames;
+ }
+ });
+ return activeClassNames;
+ }
+ case 'selectAttribute':
+ case 'selectDataAttribute': {
+ const attrName = params.attributeName;
+ let attrValue;
+ if (methodName === 'selectAttribute') {
+ attrValue = this.$target[0].getAttribute(attrName);
+ } else if (methodName === 'selectDataAttribute') {
+ attrValue = this.$target[0].dataset[attrName];
+ }
+ attrValue = (attrValue || '').trim();
+ if (params.saveUnit && !params.withUnit) {
+ attrValue = attrValue.split(/\s+/g).map(v => v + params.saveUnit).join(' ');
+ }
+ return attrValue || params.attributeDefaultValue || '';
+ }
+ case 'selectStyle': {
+ if (params.colorPrefix && params.colorNames) {
+ for (const c of params.colorNames) {
+ const className = weUtils.computeColorClasses([c], params.colorPrefix)[0];
+ if (this.$target[0].classList.contains(className)) {
+ return c;
+ }
+ }
+ }
+
+ // Disable all transitions for the duration of the style check
+ // as we want to know the final value of a property to properly
+ // update the UI.
+ this.$target[0].classList.add('o_we_force_no_transition');
+ const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition');
+
+ const styles = window.getComputedStyle(this.$target[0]);
+ const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty];
+ const cssValues = cssProps.map(cssProp => {
+ let value = styles[cssProp].trim();
+ if (cssProp === 'box-shadow') {
+ const inset = value.includes('inset');
+ let values = value.replace(/,\s/g, ',').replace('inset', '').trim().split(/\s+/g);
+ const color = values.find(s => !s.match(/^\d/));
+ values = values.join(' ').replace(color, '').trim();
+ value = `${color} ${values}${inset ? ' inset' : ''}`;
+ }
+ return value;
+ });
+ if (cssValues.length === 4 && weUtils.areCssValuesEqual(cssValues[3], cssValues[1], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+ if (cssValues.length === 3 && weUtils.areCssValuesEqual(cssValues[2], cssValues[0], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+ if (cssValues.length === 2 && weUtils.areCssValuesEqual(cssValues[1], cssValues[0], params.cssProperty, this.$target)) {
+ cssValues.pop();
+ }
+
+ _restoreTransitions();
+
+ return cssValues.join(' ');
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {string} widgetName
+ * @param {Object} params
+ * @returns {Promise<boolean>|boolean}
+ */
+ _computeWidgetVisibility: async function (widgetName, params) {
+ if (widgetName === 'move_up_opt' || widgetName === 'move_left_opt') {
+ return !this.$target.is(':first-child');
+ }
+ if (widgetName === 'move_down_opt' || widgetName === 'move_right_opt') {
+ return !this.$target.is(':last-child');
+ }
+ return true;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} el
+ * @returns {Object}
+ */
+ _extraInfoFromDescriptionElement: function (el) {
+ return {
+ title: el.getAttribute('string'),
+ options: {
+ classes: el.classList,
+ dataAttributes: el.dataset,
+ tooltip: el.title,
+ placeholder: el.getAttribute('placeholder'),
+ childNodes: [...el.childNodes],
+ },
+ };
+ },
+ /**
+ * @private
+ * @param {*}
+ * @returns {string}
+ */
+ _normalizeWidgetValue: function (value) {
+ value = `${value}`.trim(); // Force to a trimmed string
+ value = ColorpickerWidget.normalizeCSSColor(value); // If is a css color, normalize it
+ return value;
+ },
+ /**
+ * @private
+ * @param {string} widgetName
+ * @param {UserValueWidget|this|null} parent
+ * @param {string} title
+ * @param {Object} options
+ * @returns {UserValueWidget}
+ */
+ _registerUserValueWidget: function (widgetName, parent, title, options) {
+ const widget = new userValueWidgetsRegistry[widgetName](parent, title, options, this.$target);
+ if (!parent || parent === this) {
+ this._userValueWidgets.push(widget);
+ } else {
+ parent.registerSubWidget(widget);
+ }
+ return widget;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} uiFragment
+ * @returns {Promise}
+ */
+ _renderCustomWidgets: function (uiFragment) {
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {HTMLElement} uiFragment
+ * @returns {Promise}
+ */
+ _renderCustomXML: function (uiFragment) {
+ return Promise.resolve();
+ },
+ /**
+ * @private
+ * @param {jQuery} [$xml] - default to original xml content
+ * @returns {Promise}
+ */
+ _renderOriginalXML: async function ($xml) {
+ const uiFragment = document.createDocumentFragment();
+ ($xml || this.$originalUIElements).clone(true).appendTo(uiFragment);
+
+ await this._renderCustomXML(uiFragment);
+
+ // Build layouting components first
+ for (const [itemName, build] of [['we-row', _buildRowElement], ['we-collapse', _buildCollapseElement]]) {
+ uiFragment.querySelectorAll(itemName).forEach(el => {
+ const infos = this._extraInfoFromDescriptionElement(el);
+ const groupEl = build(infos.title, infos.options);
+ el.parentNode.insertBefore(groupEl, el);
+ el.parentNode.removeChild(el);
+ });
+ }
+
+ // Load widgets
+ await this._renderXMLWidgets(uiFragment);
+ await this._renderCustomWidgets(uiFragment);
+
+ if (this.isDestroyed()) {
+ // TODO there is probably better to do. This case was found only in
+ // tours, where the editor is left before the widget are fully
+ // loaded (loadMethodsData doesn't work if the widget is destroyed).
+ return uiFragment;
+ }
+
+ const validMethodNames = [];
+ for (const key in this) {
+ validMethodNames.push(key);
+ }
+ this._userValueWidgets.forEach(widget => {
+ widget.loadMethodsData(validMethodNames);
+ });
+
+ return uiFragment;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} parentEl
+ * @param {SnippetOptionWidget|UserValueWidget} parentWidget
+ * @returns {Promise}
+ */
+ _renderXMLWidgets: function (parentEl, parentWidget) {
+ const proms = [...parentEl.children].map(el => {
+ const widgetName = el.tagName.toLowerCase();
+ if (!userValueWidgetsRegistry.hasOwnProperty(widgetName)) {
+ return this._renderXMLWidgets(el, parentWidget);
+ }
+
+ const infos = this._extraInfoFromDescriptionElement(el);
+ const widget = this._registerUserValueWidget(widgetName, parentWidget || this, infos.title, infos.options);
+ return widget.insertAfter(el).then(() => {
+ // Remove the original element afterwards as the insertion
+ // operation may move some of its inner content during
+ // widget start.
+ parentEl.removeChild(el);
+
+ if (widget.isContainer()) {
+ return this._renderXMLWidgets(widget.el, widget);
+ }
+ });
+ });
+ return Promise.all(proms);
+ },
+ /**
+ * @private
+ * @param {...string} widgetNames
+ * @returns {UserValueWidget[]}
+ */
+ _requestUserValueWidgets: function (...widgetNames) {
+ const widgets = [];
+ for (const widgetName of widgetNames) {
+ let widget = null;
+ this.trigger_up('user_value_widget_request', {
+ name: widgetName,
+ onSuccess: _widget => widget = _widget,
+ });
+ if (widget) {
+ widgets.push(widget);
+ }
+ }
+ return widgets;
+ },
+ /**
+ * @private
+ * @param {function<Promise<jQuery>>} [callback]
+ * @returns {Promise}
+ */
+ _rerenderXML: async function (callback) {
+ this._userValueWidgets.forEach(widget => widget.destroy());
+ this._userValueWidgets = [];
+ this.$el.empty();
+
+ let $xml = undefined;
+ if (callback) {
+ $xml = await callback.call(this);
+ }
+
+ return this._renderOriginalXML($xml).then(uiFragment => {
+ this.$el.append(uiFragment);
+ return this.updateUI();
+ });
+ },
+ /**
+ * Activates the option associated to the given DOM element.
+ *
+ * @private
+ * @param {boolean|string} previewMode
+ * - truthy if the option is enabled for preview or if leaving it (in
+ * that second case, the value is 'reset')
+ * - false if the option should be activated for good
+ * @param {UserValueWidget} widget - the widget which triggered the option change
+ * @returns {Promise}
+ */
+ _select: async function (previewMode, widget) {
+ let $applyTo = null;
+
+ // Call each option method sequentially
+ for (const methodName of widget.getMethodsNames()) {
+ const widgetValue = widget.getValue(methodName);
+ const params = widget.getMethodsParams(methodName);
+
+ if (params.applyTo) {
+ if (!$applyTo) {
+ $applyTo = this.$(params.applyTo);
+ }
+ const proms = _.map($applyTo, subTargetEl => {
+ const proxy = createPropertyProxy(this, '$target', $(subTargetEl));
+ return this[methodName].call(proxy, previewMode, widgetValue, params);
+ });
+ await Promise.all(proms);
+ } else {
+ await this[methodName](previewMode, widgetValue, params);
+ }
+ }
+
+ // We trigger the event on elements targeted by apply-to if any as
+ // this.$target could not be in an editable element while the elements
+ // targeted by apply-to are.
+ ($applyTo || this.$target).trigger('content_changed');
+ },
+ /**
+ * Used to handle attribute or data attribute value change
+ *
+ * @param {string} value
+ * @param {Object} params
+ * @returns {string|undefined}
+ */
+ _selectAttributeHelper(value, params) {
+ if (!params.attributeName) {
+ throw new Error('Attribute name missing');
+ }
+ if (params.saveUnit && !params.withUnit) {
+ // Values that come with an unit are saved without unit as
+ // data-attribute unless told otherwise.
+ value = value.split(params.saveUnit).join('');
+ }
+ if (params.extraClass) {
+ this.$target.toggleClass(params.extraClass, params.defaultValue !== value);
+ }
+ return value;
+ },
+ /**
+ * @private
+ * @param {HTMLElement} collapseEl
+ * @param {boolean|undefined} [show]
+ */
+ _toggleCollapseEl(collapseEl, show) {
+ collapseEl.classList.toggle('active', show);
+ collapseEl.querySelector('.o_we_collapse_toggler').classList.toggle('active', show);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onCollapseTogglerClick(ev) {
+ const currentCollapseEl = ev.currentTarget.parentNode;
+ this._toggleCollapseEl(currentCollapseEl);
+ for (const collapseEl of currentCollapseEl.querySelectorAll('we-collapse')) {
+ this._toggleCollapseEl(collapseEl, false);
+ }
+ },
+ /**
+ * Called when a widget notifies a preview/change/reset.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onUserValueUpdate: async function (ev) {
+ ev.stopPropagation();
+ const widget = ev.data.widget;
+ const previewMode = ev.data.previewMode;
+
+ // First check if the updated widget or any of the widgets it triggers
+ // will require a reload or a confirmation choice by the user. If it is
+ // the case, warn the user and potentially ask if he agrees to save its
+ // current changes. If not, just do nothing.
+ let requiresReload = false;
+ if (!ev.data.previewMode && !ev.data.isSimulatedEvent) {
+ const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames);
+ const widgets = [ev.data.widget].concat(linkedWidgets);
+
+ const warnMessage = await this._checkIfWidgetsUpdateNeedWarning(widgets);
+ if (warnMessage) {
+ const okWarning = await new Promise(resolve => {
+ Dialog.confirm(this, warnMessage, {
+ confirm_callback: () => resolve(true),
+ cancel_callback: () => resolve(false),
+ });
+ });
+ if (!okWarning) {
+ return;
+ }
+ }
+
+ const reloadMessage = await this._checkIfWidgetsUpdateNeedReload(widgets);
+ requiresReload = !!reloadMessage;
+ if (requiresReload) {
+ const save = await new Promise(resolve => {
+ Dialog.confirm(this, _t("This change needs to reload the page, this will save all your changes and reload the page, are you sure you want to proceed?") + ' '
+ + (typeof reloadMessage === 'string' ? reloadMessage : ''), {
+ confirm_callback: () => resolve(true),
+ cancel_callback: () => resolve(false),
+ });
+ });
+ if (!save) {
+ return;
+ }
+ }
+ }
+
+ // Queue action so that we can later skip useless actions.
+ if (!this._actionQueues.get(widget)) {
+ this._actionQueues.set(widget, []);
+ }
+ const currentAction = {previewMode};
+ this._actionQueues.get(widget).push(currentAction);
+
+ // Ask a mutexed snippet update according to the widget value change
+ const shouldRecordUndo = (!previewMode && !ev.data.isSimulatedEvent);
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ // If some previous snippet edition in the mutex removed the target from
+ // the DOM, the widget can be destroyed, in that case the edition request
+ // is now useless and can be discarded.
+ if (this.isDestroyed()) {
+ return;
+ }
+ // Filter actions that are counterbalanced by earlier/later actions
+ const actionQueue = this._actionQueues.get(widget).filter(({previewMode}, i, actions) => {
+ const prev = actions[i - 1];
+ const next = actions[i + 1];
+ if (previewMode === true && next && next.previewMode) {
+ return false;
+ } else if (previewMode === 'reset' && prev && prev.previewMode) {
+ return false;
+ }
+ return true;
+ });
+ // Skip action if it's been counterbalanced
+ if (!actionQueue.includes(currentAction)) {
+ this._actionQueues.set(widget, actionQueue);
+ return;
+ }
+ this._actionQueues.set(widget, actionQueue.filter(action => action !== currentAction));
+
+ if (ev.data.prepare) {
+ ev.data.prepare();
+ }
+
+ if (previewMode && (widget.$el.closest('[data-no-preview="true"]').length)) {
+ // TODO the flag should be fetched through widget params somehow
+ return;
+ }
+
+ // If it is not preview mode, the user selected the option for good
+ // (so record the action)
+ if (shouldRecordUndo) {
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ }
+
+ // Call widget option methods and update $target
+ await this._select(previewMode, widget);
+ if (previewMode) {
+ return;
+ }
+
+ await new Promise(resolve => setTimeout(() => {
+ // Will update the UI of the correct widgets for all options
+ // related to the same $target/editor
+ this.trigger_up('snippet_option_update', {
+ onSuccess: () => resolve(),
+ });
+ // Set timeout needed so that the user event which triggered the
+ // option can bubble first.
+ }));
+ }});
+
+ if (ev.data.isSimulatedEvent) {
+ // If the user value update was simulated through a trigger, we
+ // prevent triggering further widgets. This could be allowed at some
+ // point but does not work correctly in complex website cases (see
+ // customizeWebsite).
+ return;
+ }
+
+ // Check linked widgets: force their value and simulate a notification
+ const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames);
+ if (linkedWidgets.length !== ev.data.triggerWidgetsNames.length) {
+ console.warn('Missing widget to trigger');
+ return;
+ }
+ let i = 0;
+ const triggerWidgetsValues = ev.data.triggerWidgetsValues;
+ for (const linkedWidget of linkedWidgets) {
+ const widgetValue = triggerWidgetsValues[i];
+ if (widgetValue !== undefined) {
+ // FIXME right now only make this work supposing it is a
+ // colorpicker widget with big big hacks, this should be
+ // improved a lot
+ const normValue = this._normalizeWidgetValue(widgetValue);
+ if (previewMode === true) {
+ linkedWidget._previewColor = normValue;
+ } else if (previewMode === false) {
+ linkedWidget._previewColor = false;
+ linkedWidget._value = normValue;
+ } else {
+ linkedWidget._previewColor = false;
+ }
+ }
+
+ linkedWidget.notifyValueChange(previewMode, true);
+ i++;
+ }
+
+ if (requiresReload) {
+ this.trigger_up('request_save', {
+ reloadEditor: true,
+ });
+ }
+ },
+ /**
+ * @private
+ */
+ _onUserValueWidgetCritical() {
+ this.trigger_up('remove_snippet', {
+ $snippet: this.$target,
+ });
+ },
+});
+const registry = {};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+registry.sizing = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var def = this._super.apply(this, arguments);
+
+ this.$handles = this.$overlay.find('.o_handle');
+
+ var resizeValues = this._getSize();
+ this.$handles.on('mousedown', function (ev) {
+ ev.preventDefault();
+
+ // First update size values as some element sizes may not have been
+ // initialized on option start (hidden slides, etc)
+ resizeValues = self._getSize();
+ var $handle = $(ev.currentTarget);
+
+ var compass = false;
+ var XY = false;
+ if ($handle.hasClass('n')) {
+ compass = 'n';
+ XY = 'Y';
+ } else if ($handle.hasClass('s')) {
+ compass = 's';
+ XY = 'Y';
+ } else if ($handle.hasClass('e')) {
+ compass = 'e';
+ XY = 'X';
+ } else if ($handle.hasClass('w')) {
+ compass = 'w';
+ XY = 'X';
+ }
+
+ var resize = resizeValues[compass];
+ if (!resize) {
+ return;
+ }
+
+ var current = 0;
+ var cssProperty = resize[2];
+ var cssPropertyValue = parseInt(self.$target.css(cssProperty));
+ _.each(resize[0], function (val, key) {
+ if (self.$target.hasClass(val)) {
+ current = key;
+ } else if (resize[1][key] === cssPropertyValue) {
+ current = key;
+ }
+ });
+ var begin = current;
+ var beginClass = self.$target.attr('class');
+ var regClass = new RegExp('\\s*' + resize[0][begin].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g');
+
+ var cursor = $handle.css('cursor') + '-important';
+ var $body = $(this.ownerDocument.body);
+ $body.addClass(cursor);
+
+ var xy = ev['page' + XY];
+ var bodyMouseMove = function (ev) {
+ ev.preventDefault();
+
+ var dd = ev['page' + XY] - xy + resize[1][begin];
+ var next = current + (current + 1 === resize[1].length ? 0 : 1);
+ var prev = current ? (current - 1) : 0;
+
+ var change = false;
+ if (dd > (2 * resize[1][next] + resize[1][current]) / 3) {
+ self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, ''));
+ self.$target.addClass(resize[0][next]);
+ current = next;
+ change = true;
+ }
+ if (prev !== current && dd < (2 * resize[1][prev] + resize[1][current]) / 3) {
+ self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, ''));
+ self.$target.addClass(resize[0][prev]);
+ current = prev;
+ change = true;
+ }
+
+ if (change) {
+ self._onResize(compass, beginClass, current);
+ self.trigger_up('cover_update');
+ $handle.addClass('o_active');
+ }
+ };
+ var bodyMouseUp = function () {
+ $body.off('mousemove', bodyMouseMove);
+ $(window).off('mouseup', bodyMouseUp);
+ $body.removeClass(cursor);
+ $handle.removeClass('o_active');
+
+ // Highlights the previews for a while
+ var $handlers = self.$overlay.find('.o_handle');
+ $handlers.addClass('o_active').delay(300).queue(function () {
+ $handlers.removeClass('o_active').dequeue();
+ });
+
+ if (begin === current) {
+ return;
+ }
+ setTimeout(function () {
+ self.trigger_up('request_history_undo_record', {
+ $target: self.$target,
+ event: 'resize_' + XY,
+ });
+ }, 0);
+ };
+ $body.on('mousemove', bodyMouseMove);
+ $(window).on('mouseup', bodyMouseUp);
+ });
+
+ return def;
+ },
+ /**
+ * @override
+ */
+ onFocus: function () {
+ this._onResize();
+ },
+ /**
+ * @override
+ */
+ onBlur: function () {
+ this.$handles.addClass('readonly');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ setTarget: function () {
+ this._super(...arguments);
+ this._onResize();
+ },
+ /**
+ * @override
+ */
+ updateUI: async function () {
+ await this._super(...arguments);
+ const resizeValues = this._getSize();
+ _.each(resizeValues, (value, key) => {
+ this.$handles.filter('.' + key).toggleClass('readonly', !value);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns an object mapping one or several cardinal direction (n, e, s, w)
+ * to an Array containing:
+ * 1) A list of classes to toggle when using this cardinal direction
+ * 2) A list of values these classes are supposed to set on a given CSS prop
+ * 3) The mentioned CSS prop
+ *
+ * Note: this object must also be saved in this.grid before being returned.
+ *
+ * @abstract
+ * @private
+ * @returns {Object}
+ */
+ _getSize: function () {},
+ /**
+ * Called when the snippet is being resized and its classes changes.
+ *
+ * @private
+ * @param {string} [compass] - resize direction ('n', 's', 'e' or 'w')
+ * @param {string} [beginClass] - attributes class at the beginning
+ * @param {integer} [current] - current increment in this.grid
+ */
+ _onResize: function (compass, beginClass, current) {
+ var self = this;
+
+ // Adapt the resize handles according to the classes and dimensions
+ var resizeValues = this._getSize();
+ var $handles = this.$overlay.find('.o_handle');
+ _.each(resizeValues, function (resizeValue, direction) {
+ var classes = resizeValue[0];
+ var values = resizeValue[1];
+ var cssProperty = resizeValue[2];
+
+ var $handle = $handles.filter('.' + direction);
+
+ var current = 0;
+ var cssPropertyValue = parseInt(self.$target.css(cssProperty));
+ _.each(classes, function (className, key) {
+ if (self.$target.hasClass(className)) {
+ current = key;
+ } else if (values[key] === cssPropertyValue) {
+ current = key;
+ }
+ });
+
+ $handle.toggleClass('o_handle_start', current === 0);
+ $handle.toggleClass('o_handle_end', current === classes.length - 1);
+ });
+
+ // Adapt the handles to fit the left, top and bottom sizes
+ var ml = this.$target.css('margin-left');
+ this.$overlay.find('.o_handle.w').css({
+ width: ml,
+ left: '-' + ml,
+ });
+ this.$overlay.find('.o_handle.e').css({
+ width: 0,
+ });
+ _.each(this.$overlay.find(".o_handle.n, .o_handle.s"), function (handle) {
+ var $handle = $(handle);
+ var direction = $handle.hasClass('n') ? 'top' : 'bottom';
+ $handle.height(self.$target.css('padding-' + direction));
+ });
+ this.$target.trigger('content_changed');
+ },
+});
+
+/**
+ * Handles the edition of padding-top and padding-bottom.
+ */
+registry['sizing_y'] = registry.sizing.extend({
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getSize: function () {
+ var nClass = 'pt';
+ var nProp = 'padding-top';
+ var sClass = 'pb';
+ var sProp = 'padding-bottom';
+ if (this.$target.is('hr')) {
+ nClass = 'mt';
+ nProp = 'margin-top';
+ sClass = 'mb';
+ sProp = 'margin-bottom';
+ }
+
+ var grid = [];
+ for (var i = 0; i <= (256 / 8); i++) {
+ grid.push(i * 8);
+ }
+ grid.splice(1, 0, 4);
+ this.grid = {
+ n: [grid.map(v => nClass + v), grid, nProp],
+ s: [grid.map(v => sClass + v), grid, sProp],
+ };
+ return this.grid;
+ },
+});
+
+/*
+ * Abstract option to be extended by the ImageOptimize and BackgroundOptimize
+ * options that handles all the common parts.
+ */
+const ImageHandlerOption = SnippetOptionWidget.extend({
+
+ /**
+ * @override
+ */
+ async willStart() {
+ const _super = this._super.bind(this);
+ await this._loadImageInfo();
+ return _super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ selectWidth(previewMode, widgetValue, params) {
+ this._getImg().dataset.resizeWidth = widgetValue;
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ setQuality(previewMode, widgetValue, params) {
+ this._getImg().dataset.quality = widgetValue;
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ glFilter(previewMode, widgetValue, params) {
+ const dataset = this._getImg().dataset;
+ if (widgetValue) {
+ dataset.glFilter = widgetValue;
+ } else {
+ delete dataset.glFilter;
+ }
+ return this._applyOptions();
+ },
+ /**
+ * @see this.selectClass for parameters
+ */
+ customFilter(previewMode, widgetValue, params) {
+ const img = this._getImg();
+ const {filterOptions} = img.dataset;
+ const {filterProperty} = params;
+ if (filterProperty === 'filterColor') {
+ widgetValue = normalizeColor(widgetValue);
+ }
+ const newOptions = Object.assign(JSON.parse(filterOptions || "{}"), {[filterProperty]: widgetValue});
+ img.dataset.filterOptions = JSON.stringify(newOptions);
+ return this._applyOptions();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeVisibility() {
+ const src = this._getImg().getAttribute('src');
+ return src && src !== '/';
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetState(methodName, params) {
+ const img = this._getImg();
+
+ // Make sure image is loaded because we need its naturalWidth
+ await new Promise((resolve, reject) => {
+ if (img.complete) {
+ resolve();
+ return;
+ }
+ img.addEventListener('load', resolve, {once: true});
+ img.addEventListener('error', resolve, {once: true});
+ });
+
+ switch (methodName) {
+ case 'selectWidth':
+ return img.naturalWidth;
+ case 'setFilter':
+ return img.dataset.filter;
+ case 'glFilter':
+ return img.dataset.glFilter || "";
+ case 'setQuality':
+ return img.dataset.quality || 75;
+ case 'customFilter': {
+ const {filterProperty} = params;
+ const options = JSON.parse(img.dataset.filterOptions || "{}");
+ const defaultValue = filterProperty === 'blend' ? 'normal' : 0;
+ return options[filterProperty] || defaultValue;
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async _renderCustomXML(uiFragment) {
+ const isLocalURL = href => new URL(href, window.location.origin).origin === window.location.origin;
+
+ const img = this._getImg();
+ if (!this.originalSrc || !['image/png', 'image/jpeg'].includes(img.dataset.mimetype)) {
+ return [...uiFragment.childNodes].forEach(node => {
+ if (node.matches('.o_we_external_warning')) {
+ node.classList.remove('d-none');
+ if (isLocalURL(img.getAttribute('src'))) {
+ const title = node.querySelector('we-title');
+ title.textContent = ` ${_t("Quality options unavailable")}`;
+ $(title).prepend('<i class="fa fa-warning" />');
+ if (img.dataset.mimetype) {
+ title.setAttribute('title', _t("Only PNG and JPEG images support quality options and image filtering"));
+ } else {
+ title.setAttribute('title', _t("Due to technical limitations, you can only change optimization settings on this image by choosing it again in the media-dialog or reuploading it (double click on the image)"));
+ }
+ }
+ } else {
+ node.remove();
+ }
+ });
+ }
+ const $select = $(uiFragment).find('we-select[data-name=width_select_opt]');
+ (await this._computeAvailableWidths()).forEach(([value, label]) => {
+ $select.append(`<we-button data-select-width="${value}">${label}</we-button>`);
+ });
+
+ if (img.dataset.mimetype !== 'image/jpeg') {
+ uiFragment.querySelector('we-range[data-set-quality]').remove();
+ }
+ },
+ /**
+ * Returns a list of valid widths for a given image.
+ *
+ * @private
+ */
+ async _computeAvailableWidths() {
+ const img = this._getImg();
+ const original = await loadImage(this.originalSrc);
+ const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth;
+ const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth());
+ this.optimizedWidth = optimizedWidth;
+ const widths = {
+ 128: '128px',
+ 256: '256px',
+ 512: '512px',
+ 1024: '1024px',
+ 1920: '1920px',
+ };
+ widths[img.naturalWidth] = _.str.sprintf(_t("%spx"), img.naturalWidth);
+ widths[optimizedWidth] = _.str.sprintf(_t("%dpx (Suggested)"), optimizedWidth);
+ widths[maxWidth] = _.str.sprintf(_t("%dpx (Original)"), maxWidth);
+ return Object.entries(widths)
+ .filter(([width]) => width <= maxWidth)
+ .sort(([v1], [v2]) => v1 - v2);
+ },
+ /**
+ * Applies all selected options on the original image.
+ *
+ * @private
+ */
+ async _applyOptions() {
+ const img = this._getImg();
+ if (!['image/jpeg', 'image/png'].includes(img.dataset.mimetype)) {
+ this.originalId = null;
+ return;
+ }
+ const dataURL = await applyModifications(img);
+ const weight = dataURL.split(',')[1].length / 4 * 3;
+ const $weight = this.$el.find('.o_we_image_weight');
+ $weight.find('> small').text(_t("New size"));
+ $weight.find('b').text(`${(weight / 1024).toFixed(1)} kb`);
+ $weight.removeClass('d-none');
+ img.classList.add('o_modified_image_to_save');
+ const loadedImg = await loadImage(dataURL, img);
+ this._applyImage(loadedImg);
+ return loadedImg;
+ },
+ /**
+ * Loads the image's attachment info.
+ *
+ * @private
+ */
+ async _loadImageInfo() {
+ const img = this._getImg();
+ await loadImageInfo(img, this._rpc.bind(this));
+ if (!img.dataset.originalId) {
+ this.originalId = null;
+ this.originalSrc = null;
+ return;
+ }
+ this.originalId = img.dataset.originalId;
+ this.originalSrc = img.dataset.originalSrc;
+ },
+ /**
+ * Sets the image's width to its suggested size.
+ *
+ * @private
+ */
+ async _autoOptimizeImage() {
+ await this._loadImageInfo();
+ await this._rerenderXML();
+ this._getImg().dataset.resizeWidth = this.optimizedWidth;
+ await this._applyOptions();
+ await this.updateUI();
+ },
+ /**
+ * Returns the image that is currently being modified.
+ *
+ * @private
+ * @abstract
+ * @returns {HTMLImageElement} the image to use for modifications
+ */
+ _getImg() {},
+ /**
+ * Computes the image's maximum display width.
+ *
+ * @private
+ * @abstract
+ * @returns {Int} the maximum width at which the image can be displayed
+ */
+ _computeMaxDisplayWidth() {},
+ /**
+ * Use the processed image when it's needed in the DOM.
+ *
+ * @private
+ * @abstract
+ * @param {HTMLImageElement} img
+ */
+ _applyImage(img) {},
+});
+
+/**
+ * Controls image width and quality.
+ */
+registry.ImageOptimize = ImageHandlerOption.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('image_changed.ImageOptimization', this._onImageChanged.bind(this));
+ this.$target.on('image_cropped.ImageOptimization', this._onImageCropped.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.ImageOptimization');
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeMaxDisplayWidth() {
+ // TODO: read widths from computed style in case container widths are not default
+ const displayWidth = this._getImg().clientWidth;
+ // If the image is in a column, it might get bigger on smaller screens.
+ // We use col-lg for this in snippets, so they get bigger on the md breakpoint
+ if (this.$target.closest('[class*="col-lg"]').length) {
+ // container and o_container_small have maximum inner width of 690px on the md breakpoint
+ if (this.$target.closest('.container, .o_container_small').length) {
+ return Math.min(1920, Math.max(displayWidth, 690));
+ }
+ // A container-fluid's max inner width is 962px on the md breakpoint
+ return Math.min(1920, Math.max(displayWidth, 962));
+ }
+ // If it's not in a col-lg, it's probably not going to change size depending on breakpoints
+ return displayWidth;
+ },
+ /**
+ * @override
+ */
+ _getImg() {
+ return this.$target[0];
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reloads image data and auto-optimizes the new image.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ async _onImageChanged(ev) {
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ await this._autoOptimizeImage();
+ this.trigger_up('cover_update');
+ }});
+ },
+ /**
+ * Available widths will change, need to rerender the width select.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ async _onImageCropped(ev) {
+ await this._rerenderXML();
+ },
+});
+
+/**
+ * Controls background image width and quality.
+ */
+registry.BackgroundOptimize = ImageHandlerOption.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.BackgroundOptimize');
+ return this._super(...arguments);
+ },
+ /**
+ * Marks the target for creation of an attachment and copies data attributes
+ * to the target so that they can be restored on this.img in later editions.
+ *
+ * @override
+ */
+ async cleanForSave() {
+ const img = this._getImg();
+ if (img.matches('.o_modified_image_to_save')) {
+ this.$target.addClass('o_modified_image_to_save');
+ Object.entries(img.dataset).forEach(([key, value]) => {
+ this.$target[0].dataset[key] = value;
+ });
+ this.$target[0].dataset.bgSrc = img.getAttribute('src');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _getImg() {
+ return this.img;
+ },
+ /**
+ * @override
+ */
+ _computeMaxDisplayWidth() {
+ return 1920;
+ },
+ /**
+ * Initializes this.img to an image with the background image url as src.
+ *
+ * @override
+ */
+ async _loadImageInfo() {
+ this.img = new Image();
+ Object.entries(this.$target[0].dataset).filter(([key]) =>
+ // Avoid copying dynamic editor attributes
+ !['oeId','oeModel', 'oeField', 'oeXpath', 'noteId'].includes(key)
+ ).forEach(([key, value]) => {
+ this.img.dataset[key] = value;
+ });
+ const src = getBgImageURL(this.$target[0]);
+ // Don't set the src if not relative (ie, not local image: cannot be modified)
+ this.img.src = src.startsWith('/') ? src : '';
+ return await this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _applyImage(img) {
+ this.$target.css('background-image', `url('${img.getAttribute('src')}')`);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Reloads image data when the background is changed.
+ *
+ * @private
+ */
+ async _onBackgroundChanged(ev, previewMode) {
+ if (!previewMode) {
+ this.trigger_up('snippet_edition_request', {exec: async () => {
+ await this._autoOptimizeImage();
+ }});
+ }
+ },
+});
+
+registry.BackgroundToggler = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('content_changed.BackgroundToggler', this._onExternalUpdate.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this._super(...arguments);
+ this.$target.off('.BackgroundToggler');
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Toggles background image on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgImage(previewMode, widgetValue, params) {
+ if (!widgetValue) {
+ // TODO: use setWidgetValue instead of calling background directly when possible
+ const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt');
+ const bgImageOpt = bgImageWidget.getParent();
+ return bgImageOpt.background(false, '', bgImageWidget.getMethodsParams('background'));
+ } else {
+ // TODO: use trigger instead of el.click when possible
+ this._requestUserValueWidgets('bg_image_opt')[0].el.click();
+ }
+ },
+ /**
+ * Toggles background shape on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgShape(previewMode, widgetValue, params) {
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const shapeOption = shapeWidget.getParent();
+ // TODO: open select after shape was selected?
+ // TODO: use setWidgetValue instead of calling shapeOption method directly when possible
+ return shapeOption._toggleShape();
+ },
+ /**
+ * Toggles background filter on or off.
+ *
+ * @see this.selectClass for parameters
+ */
+ toggleBgFilter(previewMode, widgetValue, params) {
+ if (widgetValue) {
+ const bgFilterEl = document.createElement('div');
+ bgFilterEl.classList.add('o_we_bg_filter', 'bg-black-50');
+ const lastBackgroundEl = this._getLastPreFilterLayerElement();
+ if (lastBackgroundEl) {
+ $(lastBackgroundEl).after(bgFilterEl);
+ } else {
+ this.$target.prepend(bgFilterEl);
+ }
+ } else {
+ this.$target.find('.o_we_bg_filter').remove();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'toggleBgImage': {
+ const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt');
+ const bgImageOpt = bgImageWidget.getParent();
+ return !!bgImageOpt._computeWidgetState('background', bgImageWidget.getMethodsParams('background'));
+ }
+ case 'toggleBgFilter': {
+ return this._hasBgFilter();
+ }
+ case 'toggleBgShape': {
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const shapeOption = shapeWidget.getParent();
+ return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape'));
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ */
+ _getLastPreFilterLayerElement() {
+ return null;
+ },
+ /**
+ * @private
+ * @returns {Boolean}
+ */
+ _hasBgFilter() {
+ return !!this.$target.find('> .o_we_bg_filter').length;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onExternalUpdate() {
+ if (this._hasBgFilter()
+ && !this._getLastPreFilterLayerElement()
+ && !getBgImageURL(this.$target)) {
+ // No 'pre-filter' background layout anymore and no more background
+ // image: remove the background filter option.
+ // TODO there probably is a better system to implement to do that
+ const widget = this._requestUserValueWidgets('bg_filter_toggle_opt')[0];
+ widget.enable();
+ }
+ },
+});
+
+/**
+ * Handles the edition of snippet's background image.
+ */
+registry.BackgroundImage = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Handles a background change.
+ *
+ * @see this.selectClass for parameters
+ */
+ background: async function (previewMode, widgetValue, params) {
+ if (previewMode === true) {
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ } else if (previewMode === 'reset') {
+ widgetValue = this.__customImageSrc;
+ } else {
+ this.__customImageSrc = widgetValue;
+ }
+
+ this._setBackground(widgetValue);
+
+ if (previewMode !== 'reset') {
+ removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]);
+ this.$target.trigger('background_changed', [previewMode]);
+ }
+ },
+ /**
+ * Changes the main color of dynamic SVGs.
+ *
+ * @see this.selectClass for parameters
+ */
+ async dynamicColor(previewMode, widgetValue, params) {
+ const currentSrc = getBgImageURL(this.$target[0]);
+ switch (previewMode) {
+ case true:
+ this.previousSrc = currentSrc;
+ break;
+ case 'reset':
+ this.$target.css('background-image', `url('${this.previousSrc}')`);
+ return;
+ }
+ const newURL = new URL(currentSrc, window.location.origin);
+ newURL.searchParams.set('c1', normalizeColor(widgetValue));
+ const src = newURL.pathname + newURL.search;
+ await loadImage(src);
+ this.$target.css('background-image', `url('${src}')`);
+ if (!previewMode) {
+ this.previousSrc = src;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ setTarget: function () {
+ // When we change the target of this option we need to transfer the
+ // background-image from the old target to the new one.
+ const oldBgURL = getBgImageURL(this.$target);
+ this._setBackground('');
+ this._super(...arguments);
+ if (oldBgURL) {
+ this._setBackground(oldBgURL);
+ }
+
+ // TODO should be automatic for all options as equal to the start method
+ this.__customImageSrc = getBgImageURL(this.$target[0]);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName) {
+ switch (methodName) {
+ case 'background':
+ return getBgImageURL(this.$target[0]);
+ case 'dynamicColor':
+ return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get('c1');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'dynamic_color_opt') {
+ const src = new URL(getBgImageURL(this.$target[0]), window.location.origin);
+ return src.origin === window.location.origin && src.pathname.startsWith('/web_editor/shape/');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @param {string} backgroundURL
+ */
+ _setBackground(backgroundURL) {
+ if (backgroundURL) {
+ this.$target.css('background-image', `url('${backgroundURL}')`);
+ this.$target.addClass('oe_img_bg');
+ } else {
+ this.$target.css('background-image', '');
+ this.$target.removeClass('oe_img_bg');
+ }
+ },
+});
+
+/**
+ * Handles background shapes.
+ */
+registry.BackgroundShape = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ updateUI() {
+ if (this.rerender) {
+ this.rerender = false;
+ return this._rerenderXML();
+ }
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the current background shape.
+ *
+ * @see this.selectClass for params
+ */
+ shape(previewMode, widgetValue, params) {
+ this._handlePreviewState(previewMode, () => {
+ return {shape: widgetValue, colors: this._getDefaultColors(), flip: []};
+ });
+ },
+ /**
+ * Sets the current background shape's colors.
+ *
+ * @see this.selectClass for params
+ */
+ color(previewMode, widgetValue, params) {
+ this._handlePreviewState(previewMode, () => {
+ const {colorName} = params;
+ const {colors: previousColors} = this._getShapeData();
+ const newColor = normalizeColor(widgetValue) || this._getDefaultColors()[colorName];
+ const newColors = Object.assign(previousColors, {[colorName]: newColor});
+ return {colors: newColors};
+ });
+ },
+ /**
+ * Flips the shape on its x axis.
+ *
+ * @see this.selectClass for params
+ */
+ flipX(previewMode, widgetValue, params) {
+ this._flipShape(previewMode, 'x');
+ },
+ /**
+ * Flips the shape on its y axis.
+ *
+ * @see this.selectClass for params
+ */
+ flipY(previewMode, widgetValue, params) {
+ this._flipShape(previewMode, 'y');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'shape': {
+ return this._getShapeData().shape;
+ }
+ case 'color': {
+ const {shape, colors: customColors} = this._getShapeData();
+ const colors = Object.assign(this._getDefaultColors(), customColors);
+ const color = shape && colors[params.colorName];
+ return color || '';
+ }
+ case 'flipX': {
+ // Compat: flip classes are no longer used but may be present in client db
+ const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_x').length !== 0;
+ return hasFlipClass || this._getShapeData().flip.includes('x');
+ }
+ case 'flipY': {
+ // Compat: flip classes are no longer used but may be present in client db
+ const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_y').length !== 0;
+ return hasFlipClass || this._getShapeData().flip.includes('y');
+ }
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _renderCustomXML(uiFragment) {
+ Object.keys(this._getDefaultColors()).map(colorName => {
+ uiFragment.querySelector('[data-name="colors"]')
+ .prepend($(`<we-colorpicker data-color="true" data-color-name="${colorName}">`)[0]);
+ });
+
+ uiFragment.querySelectorAll('we-select-pager we-button[data-shape]').forEach(btn => {
+ const btnContent = document.createElement('div');
+ btnContent.classList.add('o_we_shape_btn_content', 'position-relative', 'border-dark');
+ const btnContentInnerDiv = document.createElement('div');
+ btnContentInnerDiv.classList.add('o_we_shape');
+ btnContent.appendChild(btnContentInnerDiv);
+
+ const {shape} = btn.dataset;
+ const shapeEl = btnContent.querySelector('.o_we_shape');
+ shapeEl.classList.add(`o_${shape.replace(/\//g, '_')}`);
+ btn.append(btnContent);
+ });
+ return uiFragment;
+ },
+ /**
+ * @override
+ */
+ async _computeWidgetVisibility(widgetName, params) {
+ if (widgetName === 'shape_none_opt') {
+ return false;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Flips the shape on its x/y axis.
+ *
+ * @param {boolean} previewMode
+ * @param {'x'|'y'} axis the axis of the shape that should be flipped.
+ */
+ _flipShape(previewMode, axis) {
+ this._handlePreviewState(previewMode, () => {
+ const flip = new Set(this._getShapeData().flip);
+ if (flip.has(axis)) {
+ flip.delete(axis);
+ } else {
+ flip.add(axis);
+ }
+ return {flip: [...flip]};
+ });
+ },
+ /**
+ * Handles everything related to saving state before preview and restoring
+ * it after a preview or locking in the changes when not in preview.
+ *
+ * @param {boolean} previewMode
+ * @param {function} computeShapeData function to compute the new shape data.
+ */
+ _handlePreviewState(previewMode, computeShapeData) {
+ const target = this.$target[0];
+ const insertShapeContainer = newContainer => {
+ const shapeContainer = target.querySelector(':scope > .o_we_shape');
+ if (shapeContainer) {
+ shapeContainer.remove();
+ }
+ if (newContainer) {
+ const preShapeLayerElement = this._getLastPreShapeLayerElement();
+ if (preShapeLayerElement) {
+ $(preShapeLayerElement).after(newContainer);
+ } else {
+ this.$target.prepend(newContainer);
+ }
+ }
+ return newContainer;
+ };
+
+ let changedShape = false;
+ if (previewMode === 'reset') {
+ insertShapeContainer(this.prevShapeContainer);
+ if (this.prevShape) {
+ target.dataset.oeShapeData = this.prevShape;
+ } else {
+ delete target.dataset.oeShapeData;
+ }
+ return;
+ } else {
+ if (previewMode === true) {
+ const shapeContainer = target.querySelector(':scope > .o_we_shape');
+ this.prevShapeContainer = shapeContainer && shapeContainer.cloneNode(true);
+ this.prevShape = target.dataset.oeShapeData;
+ }
+ const curShapeData = target.dataset.oeShapeData || {};
+ const newShapeData = computeShapeData();
+ const {shape: curShape} = curShapeData;
+ changedShape = newShapeData.shape !== curShape;
+ this._markShape(newShapeData);
+ if (previewMode === false && changedShape) {
+ // Need to rerender for correct number of colorpickers
+ this.rerender = true;
+ }
+ }
+
+ // Updates/removes the shape container as needed and gives it the
+ // correct background shape
+ const json = target.dataset.oeShapeData;
+ const {shape, colors, flip = []} = json ? JSON.parse(json) : {};
+ let shapeContainer = target.querySelector(':scope > .o_we_shape');
+ if (!shape) {
+ return insertShapeContainer(null);
+ }
+ // When changing shape we want to reset the shape container (for transparency color)
+ if (changedShape) {
+ shapeContainer = insertShapeContainer(null);
+ }
+ if (!shapeContainer) {
+ shapeContainer = insertShapeContainer(document.createElement('div'));
+ target.style.position = 'relative';
+ shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, '_')}`;
+ }
+ // Compat: remove old flip classes as flipping is now done inside the svg
+ shapeContainer.classList.remove('o_we_flip_x', 'o_we_flip_y');
+
+ if (colors || flip.length) {
+ // Custom colors/flip, overwrite shape that is set by the class
+ $(shapeContainer).css('background-image', `url("${this._getShapeSrc()}")`);
+ shapeContainer.style.backgroundPosition = '';
+ if (flip.length) {
+ let [xPos, yPos] = $(shapeContainer)
+ .css('background-position')
+ .split(' ')
+ .map(p => parseFloat(p));
+ // -X + 2*Y is a symmetry of X around Y, this is a symmetry around 50%
+ xPos = flip.includes('x') ? -xPos + 100 : xPos;
+ yPos = flip.includes('y') ? -yPos + 100 : yPos;
+ shapeContainer.style.backgroundPosition = `${xPos}% ${yPos}%`;
+ }
+ } else {
+ // Remove custom bg image and let the shape class set the bg shape
+ $(shapeContainer).css('background-image', '');
+ $(shapeContainer).css('background-position', '');
+ }
+ if (previewMode === false) {
+ this.prevShapeContainer = shapeContainer.cloneNode(true);
+ this.prevShape = target.dataset.oeShapeData;
+ }
+ },
+ /**
+ * Overwrites shape properties with the specified data.
+ *
+ * @private
+ * @param {Object} newData an object with the new data
+ */
+ _markShape(newData) {
+ const defaultColors = this._getDefaultColors();
+ const shapeData = Object.assign(this._getShapeData(), newData);
+ const areColorsDefault = Object.entries(shapeData.colors).every(([colorName, colorValue]) => {
+ return colorValue.toLowerCase() === defaultColors[colorName].toLowerCase();
+ });
+ if (areColorsDefault) {
+ delete shapeData.colors;
+ }
+ if (!shapeData.shape) {
+ delete this.$target[0].dataset.oeShapeData;
+ } else {
+ this.$target[0].dataset.oeShapeData = JSON.stringify(shapeData);
+ }
+ },
+ /**
+ * @private
+ */
+ _getLastPreShapeLayerElement() {
+ const $filterEl = this.$target.find('> .o_we_bg_filter');
+ if ($filterEl.length) {
+ return $filterEl[0];
+ }
+ return null;
+ },
+ /**
+ * Returns the src of the shape corresponding to the current parameters.
+ *
+ * @private
+ */
+ _getShapeSrc() {
+ const {shape, colors, flip} = this._getShapeData();
+ if (!shape) {
+ return '';
+ }
+ const searchParams = Object.entries(colors)
+ .map(([colorName, colorValue]) => {
+ const encodedCol = encodeURIComponent(colorValue);
+ return `${colorName}=${encodedCol}`;
+ });
+ if (flip.length) {
+ searchParams.push(`flip=${flip.sort().join('')}`);
+ }
+ return `/web_editor/shape/${shape}.svg?${searchParams.join('&')}`;
+ },
+ /**
+ * Retrieves current shape data from the target's dataset.
+ *
+ * @private
+ * @param {HTMLElement} [target=this.$target[0]] the target on which to read
+ * the shape data.
+ */
+ _getShapeData(target = this.$target[0]) {
+ const defaultData = {
+ shape: '',
+ colors: this._getDefaultColors(),
+ flip: [],
+ };
+ const json = target.dataset.oeShapeData;
+ return json ? Object.assign(defaultData, JSON.parse(json.replace(/'/g, '"'))) : defaultData;
+ },
+ /**
+ * Returns the default colors for the currently selected shape.
+ *
+ * @private
+ */
+ _getDefaultColors() {
+ const $shapeContainer = this.$target.find('> .o_we_shape')
+ .clone()
+ .addClass('d-none')
+ // Needs to be in document for bg-image class to take effect
+ .appendTo(document.body);
+ const shapeContainer = $shapeContainer[0];
+ $shapeContainer.css('background-image', '');
+ const shapeSrc = shapeContainer && getBgImageURL(shapeContainer);
+ $shapeContainer.remove();
+ if (!shapeSrc) {
+ return {};
+ }
+ const url = new URL(shapeSrc, window.location.origin);
+ return Object.fromEntries(url.searchParams.entries());
+ },
+ /**
+ * Toggles whether there is a shape or not, to be called from bg toggler.
+ *
+ * @private
+ */
+ _toggleShape() {
+ if (this._getShapeData().shape) {
+ return this._handlePreviewState(false, () => ({shape: ''}));
+ } else {
+ const target = this.$target[0];
+ const previousSibling = target.previousElementSibling;
+ const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt');
+ const possibleShapes = shapeWidget.getMethodsParams('shape').possibleValues;
+ let shapeToSelect;
+ if (previousSibling) {
+ const previousShape = this._getShapeData(previousSibling).shape;
+ shapeToSelect = possibleShapes.find((shape, i) => {
+ return possibleShapes[i - 1] === previousShape;
+ });
+ } else {
+ shapeToSelect = possibleShapes[1];
+ }
+ return this._handlePreviewState(false, () => ({shape: shapeToSelect}));
+ }
+ },
+});
+
+/**
+ * Handles the edition of snippets' background image position.
+ */
+registry.BackgroundPosition = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._super.apply(this, arguments);
+
+ this._initOverlay();
+
+ // Resize overlay content on window resize because background images
+ // change size, and on carousel slide because they sometimes take up
+ // more space and move elements around them.
+ $(window).on('resize.bgposition', () => this._dimensionOverlay());
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._toggleBgOverlay(false);
+ $(window).off('.bgposition');
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the background type (cover/repeat pattern).
+ *
+ * @see this.selectClass for params
+ */
+ backgroundType: function (previewMode, widgetValue, params) {
+ this.$target.toggleClass('o_bg_img_opt_repeat', widgetValue === 'repeat-pattern');
+ this.$target.css('background-position', '');
+ this.$target.css('background-size', '');
+ },
+ /**
+ * Saves current background position and enables overlay.
+ *
+ * @see this.selectClass for params
+ */
+ backgroundPositionOverlay: async function (previewMode, widgetValue, params) {
+ // Updates the internal image
+ await new Promise(resolve => {
+ this.img = document.createElement('img');
+ this.img.addEventListener('load', () => resolve());
+ this.img.src = getBgImageURL(this.$target[0]);
+ });
+
+ const position = this.$target.css('background-position').split(' ').map(v => parseInt(v));
+ const delta = this._getBackgroundDelta();
+ // originalPosition kept in % for when movement in one direction doesn't make sense
+ this.originalPosition = {
+ left: position[0],
+ top: position[1],
+ };
+ // Convert % values to pixels for current position because mouse movement is in pixels
+ this.currentPosition = {
+ left: position[0] / 100 * delta.x || 0,
+ top: position[1] / 100 * delta.y || 0,
+ };
+ this._toggleBgOverlay(true);
+ },
+ /**
+ * @override
+ */
+ selectStyle: function (previewMode, widgetValue, params) {
+ if (params.cssProperty === 'background-size'
+ && !this.$target.hasClass('o_bg_img_opt_repeat')) {
+ // Disable the option when the image is in cover mode, otherwise
+ // the background-size: auto style may be forced.
+ return;
+ }
+ this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeVisibility: function () {
+ return this._super(...arguments) && !!getBgImageURL(this.$target[0]);
+ },
+ /**
+ * @override
+ */
+ _computeWidgetState: function (methodName, params) {
+ if (methodName === 'backgroundType') {
+ return this.$target.css('background-repeat') === 'repeat' ? 'repeat-pattern' : 'cover';
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Initializes the overlay, binds events to the buttons, inserts it in
+ * the DOM.
+ *
+ * @private
+ */
+ _initOverlay: function () {
+ this.$backgroundOverlay = $(qweb.render('web_editor.background_position_overlay'));
+ this.$overlayContent = this.$backgroundOverlay.find('.o_we_overlay_content');
+ this.$overlayBackground = this.$overlayContent.find('.o_overlay_background');
+
+ this.$backgroundOverlay.on('click', '.o_btn_apply', () => {
+ this.$target.css('background-position', this.$bgDragger.css('background-position'));
+ this._toggleBgOverlay(false);
+ });
+ this.$backgroundOverlay.on('click', '.o_btn_discard', () => {
+ this._toggleBgOverlay(false);
+ });
+
+ this.$backgroundOverlay.insertAfter(this.$overlay);
+ },
+ /**
+ * Sets the overlay in the right place so that the draggable background
+ * renders over the target, and size the background item like the target.
+ *
+ * @private
+ */
+ _dimensionOverlay: function () {
+ if (!this.$backgroundOverlay.is('.oe_active')) {
+ return;
+ }
+ // TODO: change #wrapwrap after web_editor rework.
+ const $wrapwrap = $('#wrapwrap');
+ const targetOffset = this.$target.offset();
+
+ this.$backgroundOverlay.css({
+ width: $wrapwrap.innerWidth(),
+ height: $wrapwrap.innerHeight(),
+ });
+
+ this.$overlayContent.offset(targetOffset);
+
+ this.$bgDragger.css({
+ width: `${this.$target.innerWidth()}px`,
+ height: `${this.$target.innerHeight()}px`,
+ });
+
+ const topPos = (parseInt(this.$overlay.css('top')) - parseInt(this.$overlayContent.css('top')));
+ this.$overlayContent.find('.o_we_overlay_buttons').css('top', `${topPos}px`);
+ },
+ /**
+ * Toggles the overlay's display and renders a background clone inside of it.
+ *
+ * @private
+ * @param {boolean} activate toggle the overlay on (true) or off (false)
+ */
+ _toggleBgOverlay: function (activate) {
+ if (!this.$backgroundOverlay || this.$backgroundOverlay.is('.oe_active') === activate) {
+ return;
+ }
+
+ if (!activate) {
+ this.$backgroundOverlay.removeClass('oe_active');
+ this.trigger_up('unblock_preview_overlays');
+ this.trigger_up('activate_snippet', {$snippet: this.$target});
+
+ $(document).off('click.bgposition');
+ return;
+ }
+
+ this.trigger_up('hide_overlay');
+ this.trigger_up('activate_snippet', {
+ $snippet: this.$target,
+ previewMode: true,
+ });
+ this.trigger_up('block_preview_overlays');
+
+ // Create empty clone of $target with same display size, make it draggable and give it a tooltip.
+ this.$bgDragger = this.$target.clone().empty();
+ // Prevent clone from being seen as editor if target is editor (eg. background on root tag)
+ this.$bgDragger.removeClass('o_editable');
+ // Some CSS child selector rules will not be applied since the clone has a different container from $target.
+ // The background-attachment property should be the same in both $target & $bgDragger, this will keep the
+ // preview more "wysiwyg" instead of getting different result when bg position saved (e.g. parallax snippet)
+ // TODO: improve this to copy all style from $target and override it with overlay related style (copying all
+ // css into $bgDragger will not work since it will change overlay content style too).
+ this.$bgDragger.css('background-attachment', this.$target.css('background-attachment'));
+ this.$bgDragger.on('mousedown', this._onDragBackgroundStart.bind(this));
+ this.$bgDragger.tooltip({
+ title: 'Click and drag the background to adjust its position!',
+ trigger: 'manual',
+ container: this.$backgroundOverlay
+ });
+
+ // Replace content of overlayBackground, activate the overlay and give it the right dimensions.
+ this.$overlayBackground.empty().append(this.$bgDragger);
+ this.$backgroundOverlay.addClass('oe_active');
+ this._dimensionOverlay();
+ this.$bgDragger.tooltip('show');
+
+ // Needs to be deferred or the click event that activated the overlay deactivates it as well.
+ // This is caused by the click event which we are currently handling bubbling up to the document.
+ window.setTimeout(() => $(document).on('click.bgposition', this._onDocumentClicked.bind(this)), 0);
+ },
+ /**
+ * Returns the difference between the target's size and the background's
+ * rendered size. Background position values in % are a percentage of this.
+ *
+ * @private
+ */
+ _getBackgroundDelta: function () {
+ const bgSize = this.$target.css('background-size');
+ if (bgSize !== 'cover') {
+ let [width, height] = bgSize.split(' ');
+ if (width === 'auto' && (height === 'auto' || !height)) {
+ return {
+ x: this.$target.outerWidth() - this.img.naturalWidth,
+ y: this.$target.outerHeight() - this.img.naturalHeight,
+ };
+ }
+ // At least one of width or height is not auto, so we can use it to calculate the other if it's not set
+ [width, height] = [parseInt(width), parseInt(height)];
+ return {
+ x: this.$target.outerWidth() - (width || (height * this.img.naturalWidth / this.img.naturalHeight)),
+ y: this.$target.outerHeight() - (height || (width * this.img.naturalHeight / this.img.naturalWidth)),
+ };
+ }
+
+ const renderRatio = Math.max(
+ this.$target.outerWidth() / this.img.naturalWidth,
+ this.$target.outerHeight() / this.img.naturalHeight
+ );
+
+ return {
+ x: this.$target.outerWidth() - Math.round(renderRatio * this.img.naturalWidth),
+ y: this.$target.outerHeight() - Math.round(renderRatio * this.img.naturalHeight),
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Drags the overlay's background image, copied to target on "Apply".
+ *
+ * @private
+ */
+ _onDragBackgroundStart: function (ev) {
+ ev.preventDefault();
+ this.$bgDragger.addClass('o_we_grabbing');
+ const $document = $(this.ownerDocument);
+ $document.on('mousemove.bgposition', this._onDragBackgroundMove.bind(this));
+ $document.one('mouseup', () => {
+ this.$bgDragger.removeClass('o_we_grabbing');
+ $document.off('mousemove.bgposition');
+ });
+ },
+ /**
+ * Drags the overlay's background image, copied to target on "Apply".
+ *
+ * @private
+ */
+ _onDragBackgroundMove: function (ev) {
+ ev.preventDefault();
+
+ const delta = this._getBackgroundDelta();
+ this.currentPosition.left = clamp(this.currentPosition.left + ev.originalEvent.movementX, [0, delta.x]);
+ this.currentPosition.top = clamp(this.currentPosition.top + ev.originalEvent.movementY, [0, delta.y]);
+
+ const percentPosition = {
+ left: this.currentPosition.left / delta.x * 100,
+ top: this.currentPosition.top / delta.y * 100,
+ };
+ // In cover mode, one delta will be 0 and dividing by it will yield Infinity.
+ // Defaulting to originalPosition in that case (can't be dragged)
+ percentPosition.left = isFinite(percentPosition.left) ? percentPosition.left : this.originalPosition.left;
+ percentPosition.top = isFinite(percentPosition.top) ? percentPosition.top : this.originalPosition.top;
+
+ this.$bgDragger.css('background-position', `${percentPosition.left}% ${percentPosition.top}%`);
+
+ function clamp(val, bounds) {
+ // We sort the bounds because when one dimension of the rendered background is
+ // larger than the container, delta is negative, and we want to use it as lower bound
+ bounds = bounds.sort();
+ return Math.max(bounds[0], Math.min(val, bounds[1]));
+ }
+ },
+ /**
+ * Deactivates the overlay if the user clicks outside of it.
+ *
+ * @private
+ */
+ _onDocumentClicked: function (ev) {
+ if (!$(ev.target).closest('.o_we_background_position_overlay')) {
+ this._toggleBgOverlay(false);
+ }
+ },
+});
+
+/**
+ * Marks color levels of any element that may get or has a color classes. This
+ * is done for the specific main colorpicker option so that those are marked on
+ * snippet drop (so that base snippet definition do not need to care about that)
+ * and on first focus (for compatibility).
+ */
+registry.ColoredLevelBackground = registry.BackgroundToggler.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this._markColorLevel();
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ onBuilt: function () {
+ this._markColorLevel();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a specific class indicating the element is colored so that nested
+ * color classes work (we support one-level). Removing it is not useful,
+ * technically the class can be added on anything that *may* receive a color
+ * class: this does not come with any CSS rule.
+ *
+ * @private
+ */
+ _markColorLevel: function () {
+ this.$target.addClass('o_colored_level');
+ },
+});
+
+/**
+ * Allows to replace a text value with the name of a database record.
+ * @todo replace this mechanism with real backend m2o field ?
+ */
+registry.many2one = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'],
+ /**
+ * @override
+ */
+ 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');
+
+ // create search button and bind search bar
+ this.$btn = $(qweb.render('web_editor.many2one.button'))
+ .prependTo(this.$el);
+
+ this.$ul = this.$btn.find('ul');
+ this.$search = this.$ul.find('li:first');
+ this.$search.find('input').on('mousedown click mouseup keyup keydown', function (e) {
+ e.stopPropagation();
+ });
+
+ // move menu item
+ setTimeout(function () {
+ self.$btn.find('a').on('click', function (e) {
+ self._clear();
+ });
+ }, 0);
+
+ // bind search input
+ this.$search.find('input')
+ .focus()
+ .on('keyup', function (e) {
+ self.$overlay.removeClass('o_overlay_hidden');
+ self._findExisting($(this).val());
+ });
+
+ // bind result
+ this.$ul.on('click', 'li:not(:first) a', function (e) {
+ self._selectRecord($(e.currentTarget));
+ });
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ onFocus: function () {
+ this.$target.attr('contentEditable', 'false');
+ this._clear();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Removes the input value and suggestions.
+ *
+ * @private
+ */
+ _clear: function () {
+ var self = this;
+ this.$search.siblings().remove();
+ self.$search.find('input').val('');
+ setTimeout(function () {
+ self.$search.find('input').focus();
+ }, 0);
+ },
+ /**
+ * Find existing record with the given name and suggest them.
+ *
+ * @private
+ * @param {string} name
+ * @returns {Promise}
+ */
+ _findExisting: function (name) {
+ var self = this;
+ var domain = [];
+ if (!name || !name.length) {
+ self.$search.siblings().remove();
+ return;
+ }
+ if (isNaN(+name)) {
+ if (this.Model !== 'res.partner') {
+ domain.push(['name', 'ilike', name]);
+ } else {
+ domain.push('|', ['name', 'ilike', name], ['email', 'ilike', name]);
+ }
+ } else {
+ domain.push(['id', '=', name]);
+ }
+
+ return this._rpc({
+ model: this.Model,
+ method: 'search_read',
+ args: [domain, this.Model === 'res.partner' ? ['name', 'display_name', 'city', 'country_id'] : ['name', 'display_name']],
+ kwargs: {
+ order: [{name: 'name', asc: false}],
+ limit: 5,
+ context: this.options.context,
+ },
+ }).then(function (result) {
+ self.$search.siblings().remove();
+ self.$search.after(qweb.render('web_editor.many2one.search', {contacts: result}));
+ });
+ },
+ /**
+ * Selects the given suggestion and displays it the proper way.
+ *
+ * @private
+ * @param {jQuery} $li
+ */
+ _selectRecord: function ($li) {
+ var self = this;
+
+ this.ID = +$li.data('id');
+ this.$target.attr('data-oe-many2one-id', this.ID).data('oe-many2one-id', this.ID);
+
+ this.trigger_up('request_history_undo_record', {$target: this.$target});
+ this.$target.trigger('content_changed');
+
+ if (self.$target.data('oe-type') === 'contact') {
+ $('[data-oe-contact-options]')
+ .filter('[data-oe-model="' + self.$target.data('oe-model') + '"]')
+ .filter('[data-oe-id="' + self.$target.data('oe-id') + '"]')
+ .filter('[data-oe-field="' + self.$target.data('oe-field') + '"]')
+ .filter('[data-oe-contact-options!="' + self.$target.data('oe-contact-options') + '"]')
+ .add(self.$target)
+ .attr('data-oe-many2one-id', self.ID).data('oe-many2one-id', self.ID)
+ .each(function () {
+ var $node = $(this);
+ var options = $node.data('oe-contact-options');
+ self._rpc({
+ model: 'ir.qweb.field.contact',
+ method: 'get_record_to_html',
+ args: [[self.ID]],
+ kwargs: {
+ options: options,
+ context: self.options.context,
+ },
+ }).then(function (html) {
+ $node.html(html);
+ });
+ });
+ } else {
+ self.$target.text($li.data('name'));
+ }
+
+ this._clear();
+ }
+});
+
+/**
+ * Allows to display a warning message on outdated snippets.
+ */
+registry.VersionControl = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'],
+
+ /**
+ * @override
+ */
+ start: function () {
+ this.trigger_up('get_snippet_versions', {
+ snippetName: this.$target[0].dataset.snippet,
+ onSuccess: snippetVersions => {
+ const isUpToDate = snippetVersions && ['vjs', 'vcss', 'vxml'].every(key => this.$target[0].dataset[key] === snippetVersions[key]);
+ if (!isUpToDate) {
+ this.$el.prepend(qweb.render('web_editor.outdated_block_message'));
+ }
+ },
+ });
+ return this._super(...arguments);
+ },
+});
+
+/**
+ * Handle the save of a snippet as a template that can be reused later
+ */
+registry.SnippetSave = SnippetOptionWidget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'],
+ isTopOption: true,
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @see this.selectClass for parameters
+ */
+ saveSnippet: function (previewMode, widgetValue, params) {
+ return new Promise(resolve => {
+ const dialog = new Dialog(this, {
+ title: _t("Save Your Block"),
+ size: 'small',
+ $content: $(qweb.render('web_editor.dialog.save_snippet', {
+ currentSnippetName: _.str.sprintf(_t("Custom %s"), this.data.snippetName),
+ })),
+ buttons: [{
+ text: _t("Save"),
+ classes: 'btn-primary',
+ close: true,
+ click: async () => {
+ const save = await new Promise(resolve => {
+ Dialog.confirm(this, _t("To save a snippet, we need to save all your previous modifications and reload the page."), {
+ buttons: [
+ {
+ text: _t("Save and Reload"),
+ classes: 'btn-primary',
+ close: true,
+ click: () => resolve(true),
+ }, {
+ text: _t("Cancel"),
+ close: true,
+ click: () => resolve(false),
+ }
+ ]
+ });
+ });
+ if (!save) {
+ return;
+ }
+ const snippetKey = this.$target[0].dataset.snippet;
+ let thumbnailURL;
+ this.trigger_up('snippet_thumbnail_url_request', {
+ key: snippetKey,
+ onSuccess: url => thumbnailURL = url,
+ });
+ let context;
+ this.trigger_up('context_get', {
+ callback: ctx => context = ctx,
+ });
+ this.trigger_up('request_save', {
+ reloadEditor: true,
+ onSuccess: async () => {
+ const snippetName = dialog.el.querySelector('.o_we_snippet_name_input').value;
+ const targetCopyEl = this.$target[0].cloneNode(true);
+ delete targetCopyEl.dataset.name;
+ // By the time onSuccess is called after request_save, the
+ // current widget has been destroyed and is orphaned, so this._rpc
+ // will not work as it can't trigger_up. For this reason, we need
+ // to bypass the service provider and use the global RPC directly
+ await rpc.query({
+ model: 'ir.ui.view',
+ method: 'save_snippet',
+ kwargs: {
+ 'name': snippetName,
+ 'arch': targetCopyEl.outerHTML,
+ 'template_key': this.options.snippets,
+ 'snippet_key': snippetKey,
+ 'thumbnail_url': thumbnailURL,
+ 'context': context,
+ },
+ });
+ },
+ });
+ },
+ }, {
+ text: _t("Discard"),
+ close: true,
+ }],
+ }).open();
+ dialog.on('closed', this, () => resolve());
+ });
+ },
+});
+
+/**
+ * Handles the dynamic colors for dynamic SVGs.
+ */
+registry.DynamicSvg = SnippetOptionWidget.extend({
+ /**
+ * @override
+ */
+ start() {
+ this.$target.on('image_changed.DynamicSvg', this._onImageChanged.bind(this));
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ this.$target.off('.DynamicSvg');
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the dynamic SVG's dynamic color.
+ *
+ * @see this.selectClass for params
+ */
+ async color(previewMode, widgetValue, params) {
+ const target = this.$target[0];
+ switch (previewMode) {
+ case true:
+ this.previousSrc = target.getAttribute('src');
+ break;
+ case 'reset':
+ target.src = this.previousSrc;
+ return;
+ }
+ const newURL = new URL(target.src, window.location.origin);
+ newURL.searchParams.set('c1', normalizeColor(widgetValue));
+ const src = newURL.pathname + newURL.search;
+ await loadImage(src);
+ target.src = src;
+ if (!previewMode) {
+ this.previousSrc = src;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ switch (methodName) {
+ case 'color':
+ return new URL(this.$target[0].src, window.location.origin).searchParams.get('c1');
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _computeVisibility(methodName, params) {
+ return this.$target.is("img[src^='/web_editor/shape/']");
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onImageChanged(methodName, params) {
+ return this.updateUI();
+ },
+});
+
+return {
+ SnippetOptionWidget: SnippetOptionWidget,
+ snippetOptionRegistry: registry,
+
+ NULL_ID: NULL_ID,
+ UserValueWidget: UserValueWidget,
+ userValueWidgetsRegistry: userValueWidgetsRegistry,
+ UnitUserValueWidget: UnitUserValueWidget,
+
+ addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes,
+ buildElement: _buildElement,
+ buildTitleElement: _buildTitleElement,
+ buildRowElement: _buildRowElement,
+ buildCollapseElement: _buildCollapseElement,
+
+ // Other names for convenience
+ Class: SnippetOptionWidget,
+ registry: registry,
+};
+});
diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js
new file mode 100644
index 00000000..3b49d1d8
--- /dev/null
+++ b/addons/web_editor/static/src/js/editor/summernote.js
@@ -0,0 +1,2527 @@
+odoo.define('web_editor.summernote', function (require) {
+'use strict';
+
+var core = require('web.core');
+require('summernote/summernote'); // wait that summernote is loaded
+var weDefaultOptions = require('web_editor.wysiwyg.default_options');
+
+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 foreColorButton = renderer.getTemplate().button(foreColorButtonLabel, {
+ className: 'note-fore-color-preview',
+ title: lang.color.foreground,
+ dropdown: renderer.getTemplate().dropdown('<li><div data-event-name="foreColor" class="colorPalette"/></li>'),
+ });
+ var backColorButton = renderer.getTemplate().button(backColorButtonLabel, {
+ className: 'note-back-color-preview',
+ title: lang.color.background,
+ dropdown: renderer.getTemplate().dropdown('<li><div data-event-name="backColor" class="colorPalette"/></li>'),
+ });
+ return recentColorButton + foreColorButton + backColorButton;
+};
+
+renderer.tplButtonInfo.checklist = function (lang, options) {
+ return '<button ' +
+ 'type="button" ' +
+ 'class="btn btn-secondary btn-sm" ' +
+ 'title="' + _t('Checklist') + '" ' +
+ 'data-event="insertCheckList" ' +
+ 'tabindex="-1" ' +
+ 'data-name="ul" ' +
+ '><i class="fa fa-check-square"></i></button>';
+};
+
+//::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+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 = weDefaultOptions.styleTags;
+
+$.summernote.pluginEvents.insertTable = function (event, editor, layoutInfo, sDim) {
+ var $editable = layoutInfo.editable();
+ $editable.focus();
+ 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];
+
+ // set selection outside of A if range is at beginning or end
+ var elem = dom.isBR(elem) ? elem.parentNode : dom.node(r.sc);
+ if (elem.tagName === "A") {
+ if (r.so === 0 && dom.firstChild(elem) === r.sc) {
+ r.ec = r.sc = dom.hasContentBefore(elem) || $(dom.createText('')).insertBefore(elem)[0];
+ r.eo = r.so = dom.nodeLength(r.sc);
+ r.select();
+ } else if (dom.nodeLength(r.sc) === r.so && dom.lastChild(elem) === r.sc) {
+ r.ec = r.sc = dom.hasContentAfter(elem) || dom.insertAfter(dom.createText(''), elem);
+ r.eo = r.so = 0;
+ r.select();
+ }
+ }
+
+ 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) {
+ // dom.node chooses the parent if node is text
+ var cur = dom.node(dom.lastChild(node.previousSibling));
+ if (!dom.isBR(cur)) {
+ // We should concat what was before with a <br>
+ $(cur).html(cur.innerHTML + br.outerHTML);
+ }
+ }
+ 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, type) {
+ var $editable = layoutInfo.editable();
+ $editable.focus();
+ $editable.data('NoteHistory').recordUndo($editable);
+
+ type = type || "UL";
+ var sorted = type === "OL";
+
+ 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;
+ if (type !== 'checklist') {
+ ul.classList.remove('o_checklist');
+ } else {
+ ul.classList.add('o_checklist');
+ }
+ parent.insertBefore(ul, node);
+ while (node.firstChild) {
+ ul.appendChild(node.firstChild);
+ }
+ parent.removeChild(node);
+ r.select();
+ return;
+
+ } else if (node.tagName === (sorted ? "OL" : "UL")) {
+
+ if (type === 'checklist' && !node.classList.contains('o_checklist')) {
+ node.classList.add('o_checklist');
+ return;
+ } else if (type === 'UL' && node.classList.contains('o_checklist')) {
+ node.classList.remove('o_checklist');
+ return;
+ }
+
+ 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");
+ if (type === 'checklist') {
+ ul.classList.add('o_checklist');
+ }
+ 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) {
+ $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, "OL");
+};
+$.summernote.pluginEvents.insertCheckList = function (event, editor, layoutInfo) {
+ $.summernote.pluginEvents.insertUnorderedList(event, editor, layoutInfo, "checklist");
+ $(range.create().sc.parentNode).trigger('input'); // to update checklist-id
+};
+$.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 previous;
+ var tagName = UL.tagName;
+ var node = UL.firstChild;
+ var ul = document.createElement(tagName);
+ ul.className = UL.className;
+ var li = document.createElement("li");
+ li.classList.add('o_indent');
+ 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;
+ if (previous) {
+ if (dom.isList(previous.lastChild)) {
+ ul = previous.lastChild;
+ } else {
+ previous.appendChild(ul);
+ }
+ } else {
+ node.parentNode.insertBefore(li, node);
+ }
+ }
+ next = dom.nextElementSibling(node);
+ if (flag) {
+ ul.appendChild(node);
+ }
+ if (node === end || $.contains(node, end)) {
+ flag = false;
+ break;
+ }
+ previous = node;
+ 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) {
+ var $succeeding = $(node).nextAll();
+ ul = node.parentNode;
+ if (dom.previousElementSibling(ul)) {
+ dom.insertAfter(node, li);
+ } else {
+ li.parentNode.insertBefore(node, li);
+ }
+ $succeeding.insertAfter(node);
+ if (!ul.children.length) {
+ if (ul.parentNode.tagName === "LI" && !dom.previousElementSibling(ul)) {
+ ul = ul.parentNode;
+ }
+ ul.parentNode.removeChild(ul);
+ }
+ flag = false;
+ break;
+ }
+
+ 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)) {
+ if (dom.isList(ancestor.parentNode)) {
+ $dom = $(ancestor.parentNode);
+ } else {
+ // 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.focus();
+ $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 && r.isOnEditable()) {
+ 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 = weDefaultOptions.fontSizes;
+$.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.childNodes[point.offset] || 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, hasBefore, hasAfter;
+ var noContent = ['none', null, undefined];
+ for (i=0; i<nodes.length; i++) {
+ node = nodes[i];
+
+ if (dom.isText(node) && !node.nodeValue) {
+ remove(node);
+ 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);
+ hasBefore = noContent.indexOf(window.getComputedStyle(node, '::before').content) === -1;
+ hasAfter = noContent.indexOf(window.getComputedStyle(node, '::after').content) === -1;
+
+ if (!className && !style && !hasBefore && !hasAfter) {
+ remove(node, node.parentNode);
+ continue;
+ }
+
+ if (font = dom.ancestor(node.previousSibling, dom.isFont)) {
+ className2 = font.getAttribute('class');
+ style2 = font.getAttribute('style');
+ if (node !== font && className === className2 && style === style2) {
+ remove(node, font);
+ 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, preview) {
+ var $editable = layoutInfo.editable();
+ $.summernote.pluginEvents.applyFont(event, editor, layoutInfo, foreColor, null, null);
+ if (!preview) {
+ editor.afterCommand($editable);
+ }
+};
+$.summernote.pluginEvents.backColor = function (event, editor, layoutInfo, backColor, preview) {
+ 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);
+ if (!preview) {
+ editor.afterCommand($editable);
+ }
+};
+
+options.onCreateLink = function (sLinkUrl) {
+ if (sLinkUrl.indexOf('mailto:') === 0 || sLinkUrl.indexOf('tel:') === 0) {
+ sLinkUrl = sLinkUrl.replace(/^tel:([0-9]+)$/, 'tel://$1');
+ } 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);
+ }
+};
+
+function mouseDownChecklist (e) {
+ if (!dom.isLi(e.target) || !$(e.target).parent('ul.o_checklist').length || e.offsetX > 0) {
+ return;
+ }
+ e.stopPropagation();
+ 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');
+ do {
+ $parents = $parents.prev('ul.o_checklist li').removeClass('o_checked');
+ } while ($parents.length);
+ } else {
+ $sublevel.addClass('o_checked');
+ var $lis;
+ do {
+ $lis = $parents.not(':has(li[id^="checklist-id"]:not(.o_checked))').prev('ul.o_checklist li:not(.o_checked)');
+ $lis.addClass('o_checked');
+ } while ($lis.length);
+ }
+}
+
+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);
+ $editable.on("mousedown", mouseDownChecklist);
+};
+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);
+ $editable.off("mousedown", mouseDownChecklist);
+ $('.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/frontend/loader.js b/addons/web_editor/static/src/js/frontend/loader.js
new file mode 100644
index 00000000..9c6cf0ef
--- /dev/null
+++ b/addons/web_editor/static/src/js/frontend/loader.js
@@ -0,0 +1,28 @@
+odoo.define('web_editor.loader', function (require) {
+'use strict';
+
+var Wysiwyg = require('web_editor.wysiwyg.root');
+
+function load(parent, textarea, options) {
+ var loading = textarea.nextElementSibling;
+ if (loading && !loading.classList.contains('o_wysiwyg_loading')) {
+ loading = null;
+ }
+
+ if (!textarea.value.match(/\S/)) {
+ textarea.value = '<p><br/></p>';
+ }
+
+ var wysiwyg = new Wysiwyg(parent, options);
+ return wysiwyg.attachTo(textarea).then(() => {
+ if (loading) {
+ loading.parentNode.removeChild(loading);
+ }
+ return wysiwyg;
+ });
+}
+
+return {
+ load: load,
+};
+});
diff --git a/addons/web_editor/static/src/js/frontend/loader_loading.js b/addons/web_editor/static/src/js/frontend/loader_loading.js
new file mode 100644
index 00000000..f5e8eda7
--- /dev/null
+++ b/addons/web_editor/static/src/js/frontend/loader_loading.js
@@ -0,0 +1,33 @@
+(function () {
+'use strict';
+
+/**
+ * This file makes sure textarea elements with a specific editor class are
+ * tweaked as soon as the DOM is ready so that they appear to be loading.
+ *
+ * They must then be loaded using standard Odoo modules system. In particular,
+ * @see web_editor.loader
+ */
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Standard loop for better browser support
+ var textareaEls = document.querySelectorAll('textarea.o_wysiwyg_loader');
+ for (var i = 0; i < textareaEls.length; i++) {
+ var textarea = textareaEls[i];
+ var wrapper = document.createElement('div');
+ wrapper.classList.add('position-relative', 'o_wysiwyg_wrapper');
+
+ var loadingElement = document.createElement('div');
+ loadingElement.classList.add('o_wysiwyg_loading');
+ var loadingIcon = document.createElement('i');
+ loadingIcon.classList.add('text-600', 'text-center',
+ 'fa', 'fa-circle-o-notch', 'fa-spin', 'fa-2x');
+ loadingElement.appendChild(loadingIcon);
+ wrapper.appendChild(loadingElement);
+
+ textarea.parentNode.insertBefore(wrapper, textarea);
+ wrapper.insertBefore(textarea, loadingElement);
+ }
+});
+
+})();
diff --git a/addons/web_editor/static/src/js/wysiwyg/fonts.js b/addons/web_editor/static/src/js/wysiwyg/fonts.js
new file mode 100644
index 00000000..257ccaf2
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/fonts.js
@@ -0,0 +1,99 @@
+odoo.define('wysiwyg.fonts', function (require) {
+'use strict';
+
+return {
+ /**
+ * Retrieves all the CSS rules which match the given parser (Regex).
+ *
+ * @param {Regex} filter
+ * @returns {Object[]} Array of CSS rules descriptions (objects). A rule is
+ * defined by 3 values: 'selector', 'css' and 'names'. 'selector'
+ * is a string which contains the whole selector, 'css' is a string
+ * which contains the css properties and 'names' is an array of the
+ * first captured groups for each selector part. E.g.: if the
+ * filter is set to match .fa-* rules and capture the icon names,
+ * the rule:
+ * '.fa-alias1::before, .fa-alias2::before { hello: world; }'
+ * will be retrieved as
+ * {
+ * selector: '.fa-alias1::before, .fa-alias2::before',
+ * css: 'hello: world;',
+ * names: ['.fa-alias1', '.fa-alias2'],
+ * }
+ */
+ cacheCssSelectors: {},
+ getCssSelectors: function (filter) {
+ if (this.cacheCssSelectors[filter]) {
+ return this.cacheCssSelectors[filter];
+ }
+ this.cacheCssSelectors[filter] = [];
+ var sheets = document.styleSheets;
+ for (var i = 0; i < sheets.length; i++) {
+ var rules;
+ try {
+ // try...catch because Firefox not able to enumerate
+ // document.styleSheets[].cssRules[] for cross-domain
+ // stylesheets.
+ rules = sheets[i].rules || sheets[i].cssRules;
+ } catch (e) {
+ console.warn("Can't read the css rules of: " + sheets[i].href, e);
+ continue;
+ }
+ if (!rules) {
+ continue;
+ }
+
+ for (var r = 0 ; r < rules.length ; r++) {
+ var selectorText = rules[r].selectorText;
+ if (!selectorText) {
+ continue;
+ }
+ var selectors = selectorText.split(/\s*,\s*/);
+ var data = null;
+ for (var s = 0; s < selectors.length; s++) {
+ var match = selectors[s].trim().match(filter);
+ if (!match) {
+ continue;
+ }
+ if (!data) {
+ data = {
+ selector: match[0],
+ css: rules[r].cssText.replace(/(^.*\{\s*)|(\s*\}\s*$)/g, ''),
+ names: [match[1]]
+ };
+ } else {
+ data.selector += (', ' + match[0]);
+ data.names.push(match[1]);
+ }
+ }
+ if (data) {
+ this.cacheCssSelectors[filter].push(data);
+ }
+ }
+ }
+ return this.cacheCssSelectors[filter];
+ },
+ /**
+ * List of font icons to load by editor. The icons are displayed in the media
+ * editor and identified like font and image (can be colored, spinned, resized
+ * with fa classes).
+ * To add font, push a new object {base, parser}
+ *
+ * - base: class who appear on all fonts
+ * - parser: regular expression used to select all font in css stylesheets
+ *
+ * @type Array
+ */
+ fontIcons: [{base: 'fa', parser: /\.(fa-(?:\w|-)+)::?before/i}],
+ /**
+ * Searches the fonts described by the @see fontIcons variable.
+ */
+ computeFonts: _.once(function () {
+ var self = this;
+ _.each(this.fontIcons, function (data) {
+ data.cssData = self.getCssSelectors(data.parser);
+ data.alias = _.flatten(_.map(data.cssData, _.property('names')));
+ });
+ }),
+};
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/root.js b/addons/web_editor/static/src/js/wysiwyg/root.js
new file mode 100644
index 00000000..57e9f65e
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/root.js
@@ -0,0 +1,91 @@
+odoo.define('web_editor.wysiwyg.root', function (require) {
+'use strict';
+
+var Widget = require('web.Widget');
+
+var assetsLoaded = false;
+
+var WysiwygRoot = Widget.extend({
+ assetLibs: ['web_editor.compiled_assets_wysiwyg'],
+ _loadLibsTplRoute: '/web_editor/public_render_template',
+
+ publicMethods: ['isDirty', 'save', 'getValue', 'setValue', 'getEditable', 'on', 'trigger', 'focus', 'saveModifiedImages'],
+
+ /**
+ * @see 'web_editor.wysiwyg' module
+ **/
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+ this._params = params;
+ this.$editor = null;
+ },
+ /**
+ * Load assets
+ *
+ * @override
+ **/
+ willStart: function () {
+ var self = this;
+
+ var $target = this.$el;
+ this.$el = null;
+
+ return this._super().then(function () {
+ // FIXME: this code works by pure luck. If the web_editor.wysiwyg
+ // JS module was requiring a delayed module, using it here right
+ // away would lead to a crash.
+ if (!assetsLoaded) {
+ var Wysiwyg = odoo.__DEBUG__.services['web_editor.wysiwyg'];
+ _.each(['getRange', 'setRange', 'setRangeFromNode'], function (methodName) {
+ WysiwygRoot[methodName] = Wysiwyg[methodName].bind(Wysiwyg);
+ });
+ assetsLoaded = true;
+ }
+
+ var Wysiwyg = self._getWysiwygContructor();
+ var instance = new Wysiwyg(self, self._params);
+ if (self.__extraAssetsForIframe) {
+ instance.__extraAssetsForIframe = self.__extraAssetsForIframe;
+ }
+ self._params = null;
+
+ _.each(self.publicMethods, function (methodName) {
+ self[methodName] = instance[methodName].bind(instance);
+ });
+
+ return instance.attachTo($target).then(function () {
+ self.$editor = instance.$editor || instance.$el;
+ });
+ });
+ },
+
+ _getWysiwygContructor: function () {
+ return odoo.__DEBUG__.services['web_editor.wysiwyg'];
+ }
+});
+
+return WysiwygRoot;
+
+});
+
+odoo.define('web_editor.wysiwyg.default_options', function (require) {
+'use strict';
+
+/**
+ * TODO this should be refactored to be done another way, same as the 'root'
+ * module that should be done another way.
+ *
+ * This allows to have access to default options that are used in the summernote
+ * editor so that they can be tweaked (instead of entirely replaced) when using
+ * the editor on an editable content.
+ */
+
+var core = require('web.core');
+
+var _lt = core._lt;
+
+return {
+ styleTags: ['p', 'pre', 'small', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote'],
+ fontSizes: [_lt('Default'), 8, 9, 10, 11, 12, 14, 18, 24, 36, 48, 62],
+};
+});
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 00000000..80f143b6
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/alt_dialog.js
@@ -0,0 +1,62 @@
+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/wysiwyg.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.media).trigger('content_changed');
+ this.final_data = this.media;
+ return this._super.apply(this, arguments);
+ },
+});
+
+
+return AltDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
new file mode 100644
index 00000000..d00abd1a
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/color_palette.js
@@ -0,0 +1,410 @@
+odoo.define('web_editor.ColorPalette', function (require) {
+'use strict';
+
+const ajax = require('web.ajax');
+const core = require('web.core');
+const session = require('web.session');
+const {ColorpickerWidget} = require('web.Colorpicker');
+const Widget = require('web.Widget');
+const summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+const weUtils = require('web_editor.utils');
+
+const qweb = core.qweb;
+
+const ColorPaletteWidget = Widget.extend({
+ // ! for xmlDependencies, see loadDependencies function
+ template: 'web_editor.snippet.option.colorpicker',
+ events: {
+ 'click .o_we_color_btn': '_onColorButtonClick',
+ 'mouseenter .o_we_color_btn': '_onColorButtonEnter',
+ 'mouseleave .o_we_color_btn': '_onColorButtonLeave',
+ 'click .o_we_colorpicker_switch_pane_btn': '_onSwitchPaneButtonClick',
+ },
+ custom_events: {
+ 'colorpicker_select': '_onColorPickerSelect',
+ 'colorpicker_preview': '_onColorPickerPreview',
+ },
+ /**
+ * @override
+ *
+ * @param {Object} [options]
+ * @param {string} [options.selectedColor] The class or css attribute color selected by default.
+ * @param {boolean} [options.resetButton=true] Whether to display or not the reset button.
+ * @param {string[]} [options.excluded=[]] Sections not to display.
+ * @param {string[]} [options.excludeSectionOf] Extra section to exclude: the one containing the named color.
+ * @param {JQuery} [options.$editable=$()] Editable content from which the custom colors are retrieved.
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.summernoteCustomColorsArray = [].concat(...summernoteCustomColors);
+ this.style = window.getComputedStyle(document.documentElement);
+ this.options = _.extend({
+ selectedColor: false,
+ resetButton: true,
+ excluded: [],
+ excludeSectionOf: null,
+ $editable: $(),
+ withCombinations: false,
+ }, options || {});
+
+ this.selectedColor = '';
+ this.resetButton = this.options.resetButton;
+ this.withCombinations = this.options.withCombinations;
+
+ this.trigger_up('request_editable', {callback: val => this.options.$editable = val});
+ },
+ /**
+ * @override
+ */
+ willStart: async function () {
+ await this._super(...arguments);
+ await ColorPaletteWidget.loadDependencies(this);
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ const res = this._super.apply(this, arguments);
+
+ const $colorSection = this.$('.o_colorpicker_sections[data-color-tab="theme-colors"]');
+ const $clpicker = qweb.has_template('web_editor.colorpicker')
+ ? $(qweb.render('web_editor.colorpicker'))
+ : $(`<colorpicker><div class="o_colorpicker_section" data-name="common"></div></colorpicker>`);
+ $clpicker.find('button').addClass('o_we_color_btn');
+ $clpicker.appendTo($colorSection);
+
+ // Remove excluded palettes (note: only hide them to still be able
+ // to remove their related colors on the DOM target)
+ _.each(this.options.excluded, exc => {
+ this.$('[data-name="' + exc + '"]').addClass('d-none');
+ });
+ if (this.options.excludeSectionOf) {
+ this.$('[data-name]:has([data-color="' + this.options.excludeSectionOf + '"])').addClass('d-none');
+ }
+
+ this.el.querySelectorAll('.o_colorpicker_section').forEach(elem => {
+ $(elem).prepend('<div>' + (elem.dataset.display || '') + '</div>');
+ });
+
+ // Render common colors
+ if (!this.options.excluded.includes('common')) {
+ const $commonColorSection = this.$('[data-name="common"]');
+ summernoteCustomColors.forEach((colorRow, i) => {
+ if (i === 0) {
+ return; // Ignore the summernote gray palette and use ours
+ }
+ const $div = $('<div/>', {class: 'clearfix'}).appendTo($commonColorSection);
+ colorRow.forEach(color => {
+ $div.append(this._createColorButton(color, ['o_common_color']));
+ });
+ });
+ }
+
+ // Compute class colors
+ const compatibilityColorNames = ['primary', 'secondary', 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'success', 'info', 'warning', 'danger'];
+ this.colorNames = [...compatibilityColorNames];
+ this.colorToColorNames = {};
+ this.el.querySelectorAll('button[data-color]').forEach(elem => {
+ const colorName = elem.dataset.color;
+ const $color = $(elem);
+ const isCCName = weUtils.isColorCombinationName(colorName);
+ if (isCCName) {
+ $color.find('.o_we_cc_preview_wrapper').addClass(`o_cc o_cc${colorName}`);
+ } else {
+ $color.addClass(`bg-${colorName}`);
+ }
+ this.colorNames.push(colorName);
+ if (!isCCName && !elem.classList.contains('d-none')) {
+ const color = weUtils.getCSSVariableValue(colorName, this.style);
+ this.colorToColorNames[color] = colorName;
+ }
+ });
+
+ // Select selected Color and build customColors.
+ // If no color is selected selectedColor is an empty string (transparent is interpreted as no color)
+ if (this.options.selectedColor) {
+ let selectedColor = this.options.selectedColor;
+ if (compatibilityColorNames.includes(selectedColor)) {
+ selectedColor = weUtils.getCSSVariableValue(selectedColor, this.style) || selectedColor;
+ }
+ selectedColor = ColorpickerWidget.normalizeCSSColor(selectedColor);
+ if (selectedColor !== 'rgba(0, 0, 0, 0)') {
+ this.selectedColor = this.colorToColorNames[selectedColor] || selectedColor;
+ }
+ }
+ this._buildCustomColors();
+ this._markSelectedColor();
+
+ // Colorpicker
+ let defaultColor = this.selectedColor;
+ if (defaultColor && !ColorpickerWidget.isCSSColor(defaultColor)) {
+ defaultColor = weUtils.getCSSVariableValue(defaultColor, this.style);
+ }
+ this.colorPicker = new ColorpickerWidget(this, {
+ defaultColor: defaultColor,
+ });
+ await this.colorPicker.prependTo($colorSection);
+
+ // TODO Added as a fix. In master, the widget should probably not be
+ // instantiated at all.
+ if (this.options.excluded.includes('custom')) {
+ this.colorPicker.$el.addClass('d-none');
+ }
+
+ return res;
+ },
+ /**
+ * Return a list of the color names used in the color palette
+ */
+ getColorNames: function () {
+ return this.colorNames;
+ },
+ /**
+ * Sets the currently selected color
+ *
+ * @param {string} color rgb[a]
+ */
+ setSelectedColor: function (color) {
+ this._selectColor({color: color});
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _buildCustomColors: function () {
+ if (this.options.excluded.includes('custom')) {
+ return;
+ }
+ this.el.querySelectorAll('.o_custom_color').forEach(el => el.remove());
+ const existingColors = new Set(this.summernoteCustomColorsArray.concat(
+ Object.keys(this.colorToColorNames)
+ ));
+ this.trigger_up('get_custom_colors', {
+ onSuccess: (colors) => {
+ colors.forEach(color => {
+ this._addCustomColor(existingColors, color);
+ });
+ },
+ });
+ weUtils.getCSSVariableValue('custom-colors', this.style).split(' ').forEach(v => {
+ const color = weUtils.getCSSVariableValue(v.substring(1, v.length - 1), this.style);
+ if (ColorpickerWidget.isCSSColor(color)) {
+ this._addCustomColor(existingColors, color);
+ }
+ });
+ _.each(this.options.$editable.find('[style*="color"]'), el => {
+ for (const colorProp of ['color', 'backgroundColor']) {
+ this._addCustomColor(existingColors, el.style[colorProp]);
+ }
+ });
+ if (this.selectedColor) {
+ this._addCustomColor(existingColors, this.selectedColor);
+ }
+ },
+ /**
+ * Add the color to the custom color section if it is not in the existingColors.
+ *
+ * @param {string[]} existingColors Colors currently in the colorpicker
+ * @param {string} color Color to add to the cuustom colors
+ */
+ _addCustomColor: function (existingColors, color) {
+ if (!color) {
+ return;
+ }
+ if (!ColorpickerWidget.isCSSColor(color)) {
+ color = weUtils.getCSSVariableValue(color, this.style);
+ }
+ const normColor = ColorpickerWidget.normalizeCSSColor(color);
+ if (!existingColors.has(normColor)) {
+ this._addCustomColorButton(normColor);
+ existingColors.add(normColor);
+ }
+ },
+ /**
+ * Add a custom button in the coresponding section.
+ *
+ * @private
+ * @param {string} color
+ * @param {string[]} classes - classes added to the button
+ * @returns {jQuery}
+ */
+ _addCustomColorButton: function (color, classes = []) {
+ classes.push('o_custom_color');
+ const $themeSection = this.$('.o_colorpicker_section[data-name="theme"]');
+ const $button = this._createColorButton(color, classes);
+ return $button.appendTo($themeSection);
+ },
+ /**
+ * Return a color button.
+ *
+ * @param {string} color
+ * @param {string[]} classes - classes added to the button
+ * @returns {jQuery}
+ */
+ _createColorButton: function (color, classes) {
+ return $('<button/>', {
+ class: 'o_we_color_btn ' + classes.join(' '),
+ style: 'background-color:' + color + ';',
+ });
+ },
+ /**
+ * Gets normalized information about a color button.
+ *
+ * @private
+ * @param {HTMLElement} buttonEl
+ * @returns {Object}
+ */
+ _getButtonInfo: function (buttonEl) {
+ const bgColor = buttonEl.style.backgroundColor;
+ return {
+ color: bgColor ? ColorpickerWidget.normalizeCSSColor(bgColor) : buttonEl.dataset.color || '',
+ target: buttonEl,
+ };
+ },
+ /**
+ * Set the selectedColor and trigger an event
+ *
+ * @param {Object} color
+ * @param {string} [eventName]
+ */
+ _selectColor: function (colorInfo, eventName) {
+ this.selectedColor = colorInfo.color = this.colorToColorNames[colorInfo.color] || colorInfo.color;
+ if (eventName) {
+ this.trigger_up(eventName, colorInfo);
+ }
+ this._buildCustomColors();
+ this._markSelectedColor();
+ this.colorPicker.setSelectedColor(colorInfo.color);
+ },
+ /**
+ * Mark the selected color
+ *
+ * @private
+ */
+ _markSelectedColor: function () {
+ this.el.querySelectorAll('button.selected').forEach(el => el.classList.remove('selected'));
+ const selectedButton = this.el.querySelector(`button[data-color="${this.selectedColor}"], button[style*="background-color:${this.selectedColor};"]`);
+ if (selectedButton) {
+ selectedButton.classList.add('selected');
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a color button is clicked.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonClick: function (ev) {
+ const buttonEl = ev.currentTarget;
+ const colorInfo = this._getButtonInfo(buttonEl);
+ this._selectColor(colorInfo, 'color_picked');
+ },
+ /**
+ * Called when a color button is entered.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonEnter: function (ev) {
+ ev.stopPropagation();
+ this.trigger_up('color_hover', this._getButtonInfo(ev.currentTarget));
+ },
+ /**
+ * Called when a color button is left the data color is the color currently selected.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorButtonLeave: function (ev) {
+ ev.stopPropagation();
+ this.trigger_up('color_leave', {
+ color: this.selectedColor,
+ target: ev.target,
+ });
+ },
+ /**
+ * Called when an update is made on the colorpicker.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPickerPreview: function (ev) {
+ this.trigger_up('color_hover', {
+ color: ev.data.cssColor,
+ target: this.colorPicker.el,
+ });
+ },
+ /**
+ * Called when a color is selected on the colorpicker (mouseup).
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onColorPickerSelect: function (ev) {
+ this._selectColor({
+ color: ev.data.cssColor,
+ target: this.colorPicker.el,
+ }, 'custom_color_picked');
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onSwitchPaneButtonClick(ev) {
+ ev.stopPropagation();
+ this.el.querySelectorAll('.o_we_colorpicker_switch_pane_btn').forEach(el => {
+ el.classList.remove('active');
+ });
+ ev.currentTarget.classList.add('active');
+ this.el.querySelectorAll('.o_colorpicker_sections').forEach(el => {
+ el.classList.toggle('d-none', el.dataset.colorTab !== ev.currentTarget.dataset.target);
+ });
+ },
+});
+
+//------------------------------------------------------------------------------
+// Static
+//------------------------------------------------------------------------------
+
+/**
+ * Load ColorPaletteWidget dependencies. This allows to load them without
+ * instantiating the widget itself.
+ *
+ * @static
+ */
+let colorpickerTemplateProm;
+ColorPaletteWidget.loadDependencies = async function (rpcCapableObj) {
+ const proms = [ajax.loadXML('/web_editor/static/src/xml/snippets.xml', qweb)];
+
+ // Public user using the editor may have a colorpalette but with
+ // the default summernote ones.
+ if (!session.is_website_user) {
+ // We can call the colorPalette multiple times but only need 1 rpc
+ if (!colorpickerTemplateProm && !qweb.has_template('web_editor.colorpicker')) {
+ colorpickerTemplateProm = rpcCapableObj._rpc({
+ model: 'ir.ui.view',
+ method: 'read_template',
+ args: ['web_editor.colorpicker'],
+ }).then(template => {
+ return qweb.add_template('<templates>' + template + '</templates>');
+ });
+ }
+ proms.push(colorpickerTemplateProm);
+ }
+
+ return Promise.all(proms);
+};
+
+return {
+ ColorPaletteWidget: ColorPaletteWidget,
+};
+});
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 00000000..516aa4be
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/dialog.js
@@ -0,0 +1,81 @@
+odoo.define('wysiwyg.widgets.Dialog', function (require) {
+'use strict';
+
+var config = require('web.config');
+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 || {};
+ if (config.device.isMobile) {
+ options.fullscreen = true;
+ }
+ 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:visible:first').focus();
+ self.$el.closest('.modal').addClass('o_web_editor_dialog');
+ self.$el.closest('.modal').on('hidden.bs.modal', self.options.onClose);
+ });
+ this.on('closed', this, function () {
+ self._toggleFullScreen();
+ this.trigger(this.destroyAction, this.final_data || null);
+ });
+ },
+ /**
+ * Only use on config.device.isMobile, it's used by mass mailing to allow the dialog opening on fullscreen
+ * @private
+ */
+ _toggleFullScreen: function() {
+ if (config.device.isMobile && !this.hasFullScreen) {
+ $('#iframe_target[isMobile="true"] #web_editor-top-edit .o_fullscreen').click();
+ }
+ },
+ //--------------------------------------------------------------------------
+ // 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();
+ },
+ /**
+ * @override
+ * @returns {*}
+ */
+ open: function() {
+ this.hasFullScreen = $(window.top.document.body).hasClass('o_field_widgetTextHtml_fullscreen');
+ this._toggleFullScreen();
+ return this._super.apply(this, arguments);
+ },
+});
+
+return SummernoteDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
new file mode 100644
index 00000000..27444e06
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/image_crop_widget.js
@@ -0,0 +1,213 @@
+odoo.define('wysiwyg.widgets.ImageCropWidget', function (require) {
+'use strict';
+
+const core = require('web.core');
+const Widget = require('web.Widget');
+const {applyModifications, cropperDataFields, activateCropper, loadImage, loadImageInfo} = require('web_editor.image_processing');
+
+const _t = core._t;
+
+const ImageCropWidget = Widget.extend({
+ template: ['wysiwyg.widgets.crop'],
+ xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
+ events: {
+ 'click.crop_options [data-action]': '_onCropOptionClick',
+ // zoom event is triggered by the cropperjs library when the user zooms.
+ 'zoom': '_onCropZoom',
+ },
+
+ /**
+ * @constructor
+ */
+ init(parent, media) {
+ this._super(...arguments);
+ this.media = media;
+ this.$media = $(media);
+ // Needed for editors in iframes.
+ this.document = media.ownerDocument;
+ // key: ratio identifier, label: displayed to user, value: used by cropper lib
+ this.aspectRatios = {
+ "0/0": {label: _t("Free"), value: 0},
+ "16/9": {label: "16:9", value: 16 / 9},
+ "4/3": {label: "4:3", value: 4 / 3},
+ "1/1": {label: "1:1", value: 1},
+ "2/3": {label: "2:3", value: 2 / 3},
+ };
+ const src = this.media.getAttribute('src');
+ const data = Object.assign({}, media.dataset);
+ this.initialSrc = src;
+ this.aspectRatio = data.aspectRatio || "0/0";
+ this.mimetype = data.mimetype || src.endsWith('.png') ? 'image/png' : 'image/jpeg';
+ },
+ /**
+ * @override
+ */
+ async willStart() {
+ await this._super.apply(this, arguments);
+ await loadImageInfo(this.media, this._rpc.bind(this));
+ if (this.media.dataset.originalSrc) {
+ this.originalSrc = this.media.dataset.originalSrc;
+ this.originalId = this.media.dataset.originalId;
+ return;
+ }
+ // Couldn't find an attachment: not croppable.
+ this.uncroppable = true;
+ },
+ /**
+ * @override
+ */
+ async start() {
+ if (this.uncroppable) {
+ this.displayNotification({
+ type: 'warning',
+ title: _t("This image is an external image"),
+ message: _t("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."),
+ });
+ return this.destroy();
+ }
+ const _super = this._super.bind(this);
+ const $cropperWrapper = this.$('.o_we_cropper_wrapper');
+
+ // Replacing the src with the original's so that the layout is correct.
+ await loadImage(this.originalSrc, this.media);
+ this.$cropperImage = this.$('.o_we_cropper_img');
+ const cropperImage = this.$cropperImage[0];
+ [cropperImage.style.width, cropperImage.style.height] = [this.$media.width() + 'px', this.$media.height() + 'px'];
+
+ // Overlaying the cropper image over the real image
+ const offset = this.$media.offset();
+ offset.left += parseInt(this.$media.css('padding-left'));
+ offset.top += parseInt(this.$media.css('padding-right'));
+ $cropperWrapper.offset(offset);
+
+ await loadImage(this.originalSrc, cropperImage);
+ await activateCropper(cropperImage, this.aspectRatios[this.aspectRatio].value, this.media.dataset);
+ core.bus.trigger('deactivate_snippet');
+
+ this._onDocumentMousedown = this._onDocumentMousedown.bind(this);
+ // We use capture so that the handler is called before other editor handlers
+ // like save, such that we can restore the src before a save.
+ this.document.addEventListener('mousedown', this._onDocumentMousedown, {capture: true});
+ return _super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ if (this.$cropperImage) {
+ this.$cropperImage.cropper('destroy');
+ this.document.removeEventListener('mousedown', this._onDocumentMousedown, {capture: true});
+ }
+ this.media.setAttribute('src', this.initialSrc);
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates the DOM image with cropped data and associates required
+ * information for a potential future save (where required cropped data
+ * attachments will be created).
+ *
+ * @private
+ */
+ async _save() {
+ // Mark the media for later creation of cropped attachment
+ this.media.classList.add('o_modified_image_to_save');
+
+ [...cropperDataFields, 'aspectRatio'].forEach(attr => {
+ delete this.media.dataset[attr];
+ const value = this._getAttributeValue(attr);
+ if (value) {
+ this.media.dataset[attr] = value;
+ }
+ });
+ delete this.media.dataset.resizeWidth;
+ this.initialSrc = await applyModifications(this.media);
+ this.$media.trigger('image_cropped');
+ this.destroy();
+ },
+ /**
+ * Returns an attribute's value for saving.
+ *
+ * @private
+ */
+ _getAttributeValue(attr) {
+ if (cropperDataFields.includes(attr)) {
+ return this.$cropperImage.cropper('getData')[attr];
+ }
+ return this[attr];
+ },
+ /**
+ * Resets the crop box to prevent it going outside the image.
+ *
+ * @private
+ */
+ _resetCropBox() {
+ this.$cropperImage.cropper('clear');
+ this.$cropperImage.cropper('crop');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a crop option is clicked -> change the crop area accordingly.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCropOptionClick(ev) {
+ const {action, value, scaleDirection} = ev.currentTarget.dataset;
+ switch (action) {
+ case 'ratio':
+ this.$cropperImage.cropper('reset');
+ this.aspectRatio = value;
+ this.$cropperImage.cropper('setAspectRatio', this.aspectRatios[this.aspectRatio].value);
+ break;
+ case 'zoom':
+ case 'reset':
+ this.$cropperImage.cropper(action, value);
+ break;
+ case 'rotate':
+ this.$cropperImage.cropper(action, value);
+ this._resetCropBox();
+ break;
+ case 'flip': {
+ const amount = this.$cropperImage.cropper('getData')[scaleDirection] * -1;
+ return this.$cropperImage.cropper(scaleDirection, amount);
+ }
+ case 'apply':
+ return this._save();
+ case 'discard':
+ return this.destroy();
+ }
+ },
+ /**
+ * Discards crop if the user clicks outside of the widget.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDocumentMousedown(ev) {
+ if (document.body.contains(ev.target) && this.$(ev.target).length === 0) {
+ return this.destroy();
+ }
+ },
+ /**
+ * Resets the cropbox on zoom to prevent crop box overflowing.
+ *
+ * @private
+ */
+ async _onCropZoom() {
+ // Wait for the zoom event to be fully processed before reseting.
+ await new Promise(res => setTimeout(res, 0));
+ this._resetCropBox();
+ },
+});
+
+return ImageCropWidget;
+});
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 00000000..2a18ba2b
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/link_dialog.js
@@ -0,0 +1,339 @@
+odoo.define('wysiwyg.widgets.LinkDialog', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Dialog = require('wysiwyg.widgets.Dialog');
+
+var dom = $.summernote.core.dom;
+var range = $.summernote.core.range;
+
+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/wysiwyg.xml'
+ ]),
+ events: _.extend({}, Dialog.prototype.events || {}, {
+ 'input': '_onAnyChange',
+ 'change [name="link_style_color"]': '_onTypeChange',
+ 'change': '_onAnyChange',
+ 'input input[name="url"]': '_onURLInput',
+ }),
+
+ /**
+ * @constructor
+ * @param {Boolean} linkInfo.isButton - whether if the target is a button element.
+ */
+ init: function (parent, options, editable, linkInfo) {
+ this.options = options || {};
+ this._super(parent, _.extend({
+ title: _t("Link to"),
+ }, this.options));
+
+ this.trigger_up('getRecordInfo', {
+ recordInfo: this.options,
+ callback: recordInfo => {
+ _.defaults(this.options, recordInfo);
+ },
+ });
+
+ this.data = linkInfo || {};
+ this.isButton = this.data.isButton;
+ // Using explicit type 'link' to preserve style when the target is <button class="...btn-link"/>.
+ this.colorsData = [
+ {type: this.isButton ? 'link' : '', label: _t("Link"), btnPreview: 'link'},
+ {type: 'primary', label: _t("Primary"), btnPreview: 'primary'},
+ {type: 'secondary', label: _t("Secondary"), btnPreview: 'secondary'},
+ // Note: by compatibility the dialog should be able to remove old
+ // colors that were suggested like the BS status colors or the
+ // alpha -> epsilon classes. This is currently done by removing
+ // all btn-* classes anyway.
+ ];
+
+ this.editable = editable;
+ this.data.className = "";
+ this.data.iniClassName = "";
+
+ var r = this.data.range;
+ this.needLabel = !r || (r.sc === r.ec && r.so === r.eo);
+
+ if (this.data.range) {
+ const $el = $(this.data.range.sc).filter(this.isButton ? "button" : "a");
+ this.data.iniClassName = $el.attr("class") || "";
+ this.colorCombinationClass = false;
+ let $node = $el;
+ while ($node.length && !$node.is('body')) {
+ const className = $node.attr('class') || '';
+ const m = className.match(/\b(o_cc\d+)\b/g);
+ if (m) {
+ this.colorCombinationClass = m[0];
+ break;
+ }
+ $node = $node.parent();
+ }
+ this.data.className = this.data.iniClassName.replace(/(^|\s+)btn(-[a-z0-9_-]*)?/gi, ' ');
+
+ var is_link = this.data.range.isOnAnchor();
+
+ var sc = r.sc;
+ var so = r.so;
+ var ec = r.ec;
+ var eo = r.eo;
+
+ var nodes;
+ if (!is_link) {
+ 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);
+ $(editable).data("range", this.data.range);
+ 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 (!is_link && nodes[i].nodeType === 1) {
+ // just use text nodes from listBetween
+ } else if (!is_link && i===0) {
+ text += nodes[i].textContent.slice(so, Infinity);
+ } else if (!is_link && 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, ' ');
+
+ var allBtnClassSuffixes = /(^|\s+)btn(-[a-z0-9_-]*)?/gi;
+ var allBtnShapes = /\s*(rounded-circle|flat)\s*/gi;
+ this.data.className = this.data.iniClassName
+ .replace(allBtnClassSuffixes, ' ')
+ .replace(allBtnShapes, ' ');
+ // 'o_submit' class will force anchor to be handled as a button in linkdialog.
+ if (/(?:s_website_form_send|o_submit)/.test(this.data.className)) {
+ this.isButton = true;
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.buttonOptsCollapseEl = this.el.querySelector('#o_link_dialog_button_opts_collapse');
+
+ this.$styleInputs = this.$('input.link-style');
+ this.$styleInputs.prop('checked', false).filter('[value=""]').prop('checked', true);
+ if (this.data.iniClassName) {
+ _.each(this.$('input[name="link_style_color"], select[name="link_style_size"] > option, select[name="link_style_shape"] > option'), el => {
+ var $option = $(el);
+ if ($option.val() && this.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);
+ this._onURLInput();
+ }
+
+ this._updateOptionsUI();
+ 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 Promise.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 data = this._getData();
+ if (data === null) {
+ return;
+ }
+ const attrs = {
+ target: data.isNewWindow ? '_blank' : '',
+ href: data.url && data.url.length ? data.url : '#',
+ class: `${data.classes.replace(/float-\w+/, '')} o_btn_preview`,
+ };
+ this.$("#link-preview").attr(attrs).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 = _.escape(this.$('input[name="label"]').val() || url);
+
+ if (label && this.data.images) {
+ for (var i = 0; i < this.data.images.length; i++) {
+ label = label.replace(/\[IMG\]/, this.data.images[i].outerHTML);
+ }
+ }
+
+ if (!this.isButton && $url.prop('required') && (!url || !$url[0].checkValidity())) {
+ return null;
+ }
+
+ const type = this.$('input[name="link_style_color"]:checked').val() || '';
+ const size = this.$('select[name="link_style_size"]').val() || '';
+ const shape = this.$('select[name="link_style_shape"]').val() || '';
+ const shapes = shape ? shape.split(',') : [];
+ const style = ['outline', 'fill'].includes(shapes[0]) ? `${shapes[0]}-` : '';
+ const shapeClasses = shapes.slice(style ? 1 : 0).join(' ');
+ const classes = (this.data.className || '') +
+ (type ? (` btn btn-${style}${type}`) : '') +
+ (shapeClasses ? (` ${shapeClasses}`) : '') +
+ (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);
+ } else if (url.indexOf(location.origin) === 0 && this.$('#o_link_dialog_url_strip_domain').prop("checked")) {
+ url = url.slice(location.origin.length);
+ }
+ var allWhitespace = /\s+/gi;
+ var allStartAndEndSpace = /^\s+|\s+$/gi;
+ return {
+ label: label,
+ url: url,
+ classes: classes.replace(allWhitespace, ' ').replace(allStartAndEndSpace, ''),
+ isNewWindow: isNewWindow,
+ };
+ },
+ /**
+ * @private
+ */
+ _updateOptionsUI: function () {
+ const el = this.el.querySelector('[name="link_style_color"]:checked');
+ $(this.buttonOptsCollapseEl).collapse(el && el.value ? 'show' : 'hide');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAnyChange: function () {
+ this._adaptPreview();
+ },
+ /**
+ * @private
+ */
+ _onTypeChange() {
+ this._updateOptionsUI();
+ },
+ /**
+ * @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');
+ let value = $linkUrlInput.val();
+ let isLink = value.indexOf('@') < 0;
+ this.$('input[name="is_new_window"]').closest('.form-group').toggleClass('d-none', !isLink);
+ this.$('.o_strip_domain').toggleClass('d-none', value.indexOf(window.location.origin) !== 0);
+ },
+});
+
+return LinkDialog;
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
new file mode 100644
index 00000000..99ecabb1
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js
@@ -0,0 +1,1463 @@
+odoo.define('wysiwyg.widgets.media', function (require) {
+'use strict';
+
+var concurrency = require('web.concurrency');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var dom = require('web.dom');
+var fonts = require('wysiwyg.fonts');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+var session = require('web.session');
+const {removeOnImageChangeAttrs} = require('web_editor.image_processing');
+const {getCSSVariableValue, DEFAULT_PALETTE} = require('web_editor.utils');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+var MediaWidget = Widget.extend({
+ xmlDependencies: ['/web_editor/static/src/xml/wysiwyg.xml'],
+
+ /**
+ * @constructor
+ * @param {Element} media: the target Element for which we select a media
+ * @param {Object} options: useful parameters such as res_id, res_model,
+ * context, user_id, ...
+ */
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this.media = media;
+ this.$media = $(media);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @todo comment
+ */
+ clear: function () {
+ if (!this.media) {
+ return;
+ }
+ this._clear();
+ },
+ /**
+ * Saves the currently configured media on the target media.
+ *
+ * @abstract
+ * @returns {Promise}
+ */
+ save: function () {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @abstract
+ */
+ _clear: function () {},
+});
+
+var SearchableMediaWidget = MediaWidget.extend({
+ events: _.extend({}, MediaWidget.prototype.events || {}, {
+ 'input .o_we_search': '_onSearchInput',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this._onSearchInput = _.debounce(this._onSearchInput, 500);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Finds and displays existing attachments related to the target media.
+ *
+ * @abstract
+ * @param {string} needle: only return attachments matching this parameter
+ * @returns {Promise}
+ */
+ search: function (needle) {},
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Renders thumbnails for the attachments.
+ *
+ * @abstract
+ * @returns {Promise}
+ */
+ _renderThumbnails: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onSearchInput: function (ev) {
+ this.attachments = [];
+ this.search($(ev.currentTarget).val() || '').then(() => this._renderThumbnails());
+ this.hasSearched = true;
+ },
+});
+
+/**
+ * Let users choose a file, including uploading a new file in odoo.
+ */
+var FileWidget = SearchableMediaWidget.extend({
+ events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
+ 'click .o_upload_media_button': '_onUploadButtonClick',
+ 'change .o_file_input': '_onFileInputChange',
+ 'click .o_upload_media_url_button': '_onUploadURLButtonClick',
+ 'input .o_we_url_input': '_onURLInputChange',
+ 'click .o_existing_attachment_cell': '_onAttachmentClick',
+ 'click .o_existing_attachment_remove': '_onRemoveClick',
+ 'click .o_load_more': '_onLoadMoreClick',
+ }),
+ existingAttachmentsTemplate: undefined,
+
+ IMAGE_MIMETYPES: ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png', 'image/svg+xml'],
+ NUMBER_OF_ATTACHMENTS_TO_DISPLAY: 30,
+ MAX_DB_ATTACHMENTS: 5,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this._mutex = new concurrency.Mutex();
+
+ this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
+
+ this.options = _.extend({
+ mediaWidth: media && media.parentElement && $(media.parentElement).width(),
+ useMediaLibrary: true,
+ }, options || {});
+
+ this.attachments = [];
+ this.selectedAttachments = [];
+ this.libraryMedia = [];
+ this.selectedMedia = [];
+
+ this._onUploadURLButtonClick = dom.makeAsyncHandler(this._onUploadURLButtonClick);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ var self = this;
+ this.$urlInput = this.$('.o_we_url_input');
+ this.$form = this.$('form');
+ this.$fileInput = this.$('.o_file_input');
+ this.$uploadButton = this.$('.o_upload_media_button');
+ this.$addUrlButton = this.$('.o_upload_media_url_button');
+ this.$urlSuccess = this.$('.o_we_url_success');
+ this.$urlWarning = this.$('.o_we_url_warning');
+ this.$urlError = this.$('.o_we_url_error');
+ this.$errorText = this.$('.o_we_error_text');
+
+ // If there is already an attachment on the target, select by default
+ // that attachment if it is among the loaded images.
+ var o = {
+ url: null,
+ alt: null,
+ };
+ if (this.$media.is('img')) {
+ o.url = this.$media.attr('src');
+ } else if (this.$media.is('a.o_image')) {
+ o.url = this.$media.attr('href').replace(/[?].*/, '');
+ o.id = +o.url.match(/\/web\/content\/(\d+)/, '')[1];
+ }
+
+ return this.search('').then(async () => {
+ await this._renderThumbnails();
+ if (o.url) {
+ self._selectAttachement(_.find(self.attachments, function (attachment) {
+ return o.url === attachment.image_src;
+ }) || o);
+ }
+ return def;
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Saves the currently selected image on the target media. If new files are
+ * currently being added, delays the save until all files have been added.
+ *
+ * @override
+ */
+ save: function () {
+ return this._mutex.exec(this._save.bind(this));
+ },
+ /**
+ * @override
+ */
+ search: function (needle) {
+ this.needle = needle;
+ return this.fetchAttachments(this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY, 0);
+ },
+ /**
+ * @param {Number} number - the number of attachments to fetch
+ * @param {Number} offset - from which result to start fetching
+ */
+ fetchAttachments: function (number, offset) {
+ return this._rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ args: [],
+ kwargs: {
+ domain: this._getAttachmentsDomain(this.needle),
+ fields: ['name', 'mimetype', 'description', 'checksum', 'url', 'type', 'res_id', 'res_model', 'public', 'access_token', 'image_src', 'image_width', 'image_height', 'original_id'],
+ order: [{name: 'id', asc: false}],
+ context: this.options.context,
+ // Try to fetch first record of next page just to know whether there is a next page.
+ limit: number + 1,
+ offset: offset,
+ },
+ }).then(attachments => {
+ this.attachments = this.attachments.slice();
+ Array.prototype.splice.apply(this.attachments, [offset, attachments.length].concat(attachments));
+ });
+ },
+ /**
+ * Computes whether there is content to display in the template.
+ */
+ hasContent() {
+ return this.attachments.length;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ this.media.className = this.media.className && this.media.className.replace(/(^|\s+)(o_image)(?=\s|$)/g, ' ');
+ },
+ /**
+ * 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 = domain.concat(this.options.mimetypeDomain);
+ if (needle && needle.length) {
+ domain.push(['name', 'ilike', needle]);
+ }
+ if (!this.options.useMediaLibrary) {
+ domain.push('|', ['url', '=', false], '!', ['url', '=ilike', '/web_editor/shape/%']);
+ }
+ domain.push('!', ['name', '=like', '%.crop']);
+ domain.push('|', ['type', '=', 'binary'], '!', ['url', '=like', '/%/static/%']);
+ return domain;
+ },
+ /**
+ * @private
+ */
+ _highlightSelected: function () {
+ var self = this;
+ this.$('.o_existing_attachment_cell.o_we_attachment_selected').removeClass("o_we_attachment_selected");
+ _.each(this.selectedAttachments, function (attachment) {
+ self.$('.o_existing_attachment_cell[data-id=' + attachment.id + ']')
+ .addClass("o_we_attachment_selected").css('display', '');
+ });
+ },
+ /**
+ * @private
+ * @param {object} attachment
+ */
+ _handleNewAttachment: function (attachment) {
+ this.attachments = this.attachments.filter(att => att.id !== attachment.id);
+ this.attachments.unshift(attachment);
+ this._renderThumbnails();
+ this._selectAttachement(attachment);
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _loadMoreImages: function (forceSearch) {
+ return this.fetchAttachments(10, this.numberOfAttachmentsToDisplay).then(() => {
+ this.numberOfAttachmentsToDisplay += 10;
+ if (!forceSearch) {
+ this._renderThumbnails();
+ return Promise.resolve();
+ } else {
+ return this.search(this.$('.o_we_search').val() || '');
+ }
+ });
+ },
+ /**
+ * Renders the existing attachments and returns the result as a string.
+ *
+ * @param {Object[]} attachments
+ * @returns {string}
+ */
+ _renderExisting: function (attachments) {
+ return QWeb.render(this.existingAttachmentsTemplate, {
+ attachments: attachments,
+ widget: this,
+ });
+ },
+ /**
+ * @private
+ */
+ _renderThumbnails: function () {
+ var attachments = this.attachments.slice(0, this.numberOfAttachmentsToDisplay);
+
+ // Render menu & content
+ this.$('.o_we_existing_attachments').replaceWith(
+ this._renderExisting(attachments)
+ );
+
+ this._highlightSelected();
+
+ // adapt load more
+ this.$('.o_we_load_more').toggleClass('d-none', !this.hasContent());
+ var noLoadMoreButton = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY >= this.attachments.length;
+ var noMoreImgToLoad = this.numberOfAttachmentsToDisplay >= this.attachments.length;
+ this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
+ this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _save: async function () {
+ // Create all media-library attachments.
+ const toSave = Object.fromEntries(this.selectedMedia.map(media => [
+ media.id, {
+ query: media.query || '',
+ is_dynamic_svg: !!media.isDynamicSVG,
+ }
+ ]));
+ let mediaAttachments = [];
+ if (Object.keys(toSave).length !== 0) {
+ mediaAttachments = await this._rpc({
+ route: '/web_editor/save_library_media',
+ params: {
+ media: toSave,
+ },
+ });
+ }
+ const selected = this.selectedAttachments.concat(mediaAttachments).map(attachment => {
+ // Color-customize dynamic SVGs with the primary theme color
+ if (attachment.image_src && attachment.image_src.startsWith('/web_editor/shape/')) {
+ const colorCustomizedURL = new URL(attachment.image_src, window.location.origin);
+ colorCustomizedURL.searchParams.set('c1', getCSSVariableValue('o-color-1'));
+ attachment.image_src = colorCustomizedURL.pathname + colorCustomizedURL.search;
+ }
+ return attachment;
+ });
+ if (this.options.multiImages) {
+ return selected;
+ }
+
+ const img = selected[0];
+ if (!img || !img.id || this.$media.attr('src') === img.image_src) {
+ return this.media;
+ }
+
+ if (!img.public && !img.access_token) {
+ await this._rpc({
+ model: 'ir.attachment',
+ method: 'generate_access_token',
+ args: [[img.id]]
+ }).then(function (access_token) {
+ img.access_token = access_token[0];
+ });
+ }
+
+ if (img.image_src) {
+ var src = img.image_src;
+ if (!img.public && img.access_token) {
+ src += _.str.sprintf('?access_token=%s', img.access_token);
+ }
+ if (!this.$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.
+ this.$media = $('<img/>', {class: 'img-fluid o_we_custom_image'});
+ this.media = this.$media[0];
+ }
+ this.$media.attr('src', src);
+ } else {
+ if (!this.$media.is('a')) {
+ $('.note-control-selection').hide();
+ this.$media = $('<a/>');
+ this.media = this.$media[0];
+ }
+ var href = '/web/content/' + img.id + '?';
+ if (!img.public && img.access_token) {
+ href += _.str.sprintf('access_token=%s&', img.access_token);
+ }
+ href += 'unique=' + img.checksum + '&download=true';
+ this.$media.attr('href', href);
+ this.$media.addClass('o_image').attr('title', img.name);
+ }
+
+ this.$media.attr('alt', img.alt || img.description || '');
+ var style = this.style;
+ if (style) {
+ this.$media.css(style);
+ }
+
+ // Remove image modification attributes
+ removeOnImageChangeAttrs.forEach(attr => {
+ delete this.media.dataset[attr];
+ });
+ // Add mimetype for documents
+ if (!img.image_src) {
+ this.media.dataset.mimetype = img.mimetype;
+ }
+ this.media.classList.remove('o_modified_image_to_save');
+ this.$media.trigger('image_changed');
+ return this.media;
+ },
+ /**
+ * @param {object} attachment
+ * @param {boolean} [save=true] to save the given attachment in the DOM and
+ * and to close the media dialog
+ * @private
+ */
+ _selectAttachement: function (attachment, save, {type = 'attachment'} = {}) {
+ const possibleProps = {
+ 'attachment': 'selectedAttachments',
+ 'media': 'selectedMedia'
+ };
+ const prop = possibleProps[type];
+ if (this.options.multiImages) {
+ // if the clicked attachment is already selected then unselect it
+ // unless it was a save request (then keep the current selection)
+ const index = this[prop].indexOf(attachment);
+ if (index !== -1) {
+ if (!save) {
+ this[prop].splice(index, 1);
+ }
+ } else {
+ // if the clicked attachment is not selected, add it to selected
+ this[prop].push(attachment);
+ }
+ } else {
+ Object.values(possibleProps).forEach(prop => {
+ this[prop] = [];
+ });
+ // select the clicked attachment
+ this[prop] = [attachment];
+ }
+ this._highlightSelected();
+ if (save) {
+ this.trigger_up('save_request');
+ }
+ },
+ /**
+ * Updates the add by URL UI.
+ *
+ * @private
+ * @param {boolean} emptyValue
+ * @param {boolean} isURL
+ * @param {boolean} isImage
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this.$addUrlButton.toggleClass('btn-secondary', emptyValue)
+ .toggleClass('btn-primary', !emptyValue)
+ .prop('disabled', !isURL);
+ this.$urlSuccess.toggleClass('d-none', !isURL);
+ this.$urlError.toggleClass('d-none', emptyValue || isURL);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAttachmentClick: function (ev) {
+ const attachment = ev.currentTarget;
+ const {id: attachmentID, mediaId} = attachment.dataset;
+ if (attachmentID) {
+ const attachment = this.attachments.find(attachment => attachment.id === parseInt(attachmentID));
+ this._selectAttachement(attachment, !this.options.multiImages);
+ } else if (mediaId) {
+ const media = this.libraryMedia.find(media => media.id === parseInt(mediaId));
+ this._selectAttachement(media, !this.options.multiImages, {type: 'media'});
+ }
+ },
+ /**
+ * Handles change of the file input: create attachments with the new files
+ * and open the Preview dialog for each of them. Locks the save button until
+ * all new files have been processed.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _onFileInputChange: function () {
+ return this._mutex.exec(this._addData.bind(this));
+ },
+ /**
+ * Uploads the files that are currently selected on the file input, which
+ * creates new attachments. Then inserts them on the media dialog and
+ * selects them. If multiImages is not set, also triggers up the
+ * save_request event to insert the attachment in the DOM.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ async _addData() {
+ let files = this.$fileInput[0].files;
+ if (!files.length) {
+ // Case if the input is emptied, return resolved promise
+ return;
+ }
+
+ var self = this;
+ var uploadMutex = new concurrency.Mutex();
+
+ // Upload the smallest file first to block the user the least possible.
+ files = _.sortBy(files, 'size');
+ _.each(files, function (file) {
+ // Upload one file at a time: no need to parallel as upload is
+ // limited by bandwidth.
+ uploadMutex.exec(function () {
+ return utils.getDataURLFromFile(file).then(function (result) {
+ return self._rpc({
+ route: '/web_editor/attachment/add_data',
+ params: {
+ 'name': file.name,
+ 'data': result.split(',')[1],
+ 'res_id': self.options.res_id,
+ 'res_model': self.options.res_model,
+ 'width': 0,
+ 'quality': 0,
+ },
+ }).then(function (attachment) {
+ self._handleNewAttachment(attachment);
+ });
+ });
+ });
+ });
+
+ return uploadMutex.getUnlockedDef().then(function () {
+ if (!self.options.multiImages && !self.noSave) {
+ self.trigger_up('save_request');
+ }
+ self.noSave = false;
+ });
+ },
+ /**
+ * @private
+ */
+ _onRemoveClick: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ Dialog.confirm(this, _t("Are you sure you want to delete this file ?"), {
+ confirm_callback: function () {
+ var $a = $(ev.currentTarget).closest('.o_existing_attachment_cell');
+ var id = parseInt($a.data('id'), 10);
+ var attachment = _.findWhere(self.attachments, {id: id});
+ return self._rpc({
+ route: '/web_editor/attachment/remove',
+ params: {
+ ids: [id],
+ },
+ }).then(function (prevented) {
+ if (_.isEmpty(prevented)) {
+ self.attachments = _.without(self.attachments, attachment);
+ self.attachments.filter(at => at.original_id[0] === attachment.id).forEach(at => delete at.original_id);
+ if (!self.attachments.length) {
+ self._renderThumbnails(); //render the message and image if empty
+ } else {
+ $a.closest('.o_existing_attachment_cell').remove();
+ }
+ return;
+ }
+ self.$errorText.replaceWith(QWeb.render('wysiwyg.widgets.image.existing.error', {
+ views: prevented[id],
+ widget: self,
+ }));
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ */
+ _onURLInputChange: function () {
+ var inputValue = this.$urlInput.val();
+ var emptyValue = (inputValue === '');
+
+ var isURL = /^.+\..+$/.test(inputValue); // TODO improve
+ var isImage = _.any(['.gif', '.jpeg', '.jpe', '.jpg', '.png'], function (format) {
+ return inputValue.endsWith(format);
+ });
+
+ this._updateAddUrlUi(emptyValue, isURL, isImage);
+ },
+ /**
+ * @private
+ */
+ _onUploadButtonClick: function () {
+ this.$fileInput.click();
+ },
+ /**
+ * @private
+ */
+ _onUploadURLButtonClick: function () {
+ if (this.$urlInput.is('.o_we_horizontal_collapse')) {
+ this.$urlInput.removeClass('o_we_horizontal_collapse');
+ this.$addUrlButton.attr('disabled', 'disabled');
+ return;
+ }
+ return this._mutex.exec(this._addUrl.bind(this));
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _addUrl: function () {
+ var self = this;
+ return this._rpc({
+ route: '/web_editor/attachment/add_url',
+ params: {
+ 'url': this.$urlInput.val(),
+ 'res_id': this.options.res_id,
+ 'res_model': this.options.res_model,
+ },
+ }).then(function (attachment) {
+ self.$urlInput.val('');
+ self._onURLInputChange();
+ self._handleNewAttachment(attachment);
+ if (!self.options.multiImages) {
+ self.trigger_up('save_request');
+ }
+ });
+ },
+ /**
+ * @private
+ */
+ _onLoadMoreClick: function () {
+ this._loadMoreImages();
+ },
+ /**
+ * @override
+ */
+ _onSearchInput: function () {
+ this.attachments = [];
+ this.numberOfAttachmentsToDisplay = this.NUMBER_OF_ATTACHMENTS_TO_DISPLAY;
+ this._super.apply(this, arguments);
+ },
+});
+
+/**
+ * Let users choose an image, including uploading a new image in odoo.
+ */
+var ImageWidget = FileWidget.extend({
+ template: 'wysiwyg.widgets.image',
+ existingAttachmentsTemplate: 'wysiwyg.widgets.image.existing.attachments',
+ events: Object.assign({}, FileWidget.prototype.events, {
+ 'change input.o_we_show_optimized': '_onShowOptimizedChange',
+ 'change .o_we_search_select': '_onSearchSelect',
+ }),
+ MIN_ROW_HEIGHT: 128,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ this.searchService = 'all';
+ options = _.extend({
+ accept: 'image/*',
+ mimetypeDomain: [['mimetype', 'in', this.IMAGE_MIMETYPES]],
+ }, options || {});
+ // Binding so we can add/remove it as an addEventListener
+ this._onAttachmentImageLoad = this._onAttachmentImageLoad.bind(this);
+ this._super(parent, media, options);
+ },
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+ this.el.addEventListener('load', this._onAttachmentImageLoad, true);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this.el.removeEventListener('load', this._onAttachmentImageLoad, true);
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ async fetchAttachments(number, offset) {
+ if (this.needle && this.searchService !== 'database') {
+ number = this.MAX_DB_ATTACHMENTS;
+ offset = 0;
+ }
+ const result = await this._super(number, offset);
+ // Color-substitution for dynamic SVG attachment
+ const primaryColor = getCSSVariableValue('o-color-1');
+ this.attachments.forEach(attachment => {
+ if (attachment.image_src.startsWith('/')) {
+ const newURL = new URL(attachment.image_src, window.location.origin);
+ // Set the main color of dynamic SVGs to o-color-1
+ if (attachment.image_src.startsWith('/web_editor/shape/')) {
+ newURL.searchParams.set('c1', primaryColor);
+ } else {
+ // Set height so that db images load faster
+ newURL.searchParams.set('height', 2 * this.MIN_ROW_HEIGHT);
+ }
+ attachment.thumbnail_src = newURL.pathname + newURL.search;
+ }
+ });
+ if (this.needle && this.options.useMediaLibrary) {
+ try {
+ const response = await this._rpc({
+ route: '/web_editor/media_library_search',
+ params: {
+ 'query': this.needle,
+ 'offset': this.libraryMedia.length,
+ },
+ });
+ const newMedia = response.media;
+ this.nbMediaResults = response.results;
+ this.libraryMedia.push(...newMedia);
+ } catch (e) {
+ // Either API endpoint doesn't exist or is misconfigured.
+ console.error(`Couldn't reach API endpoint.`);
+ }
+ }
+ return result;
+ },
+ /**
+ * @override
+ */
+ hasContent() {
+ if (this.searchService === 'all') {
+ return this._super(...arguments) || this.libraryMedia.length;
+ } else if (this.searchService === 'media-library') {
+ return !!this.libraryMedia.length;
+ }
+ return this._super(...arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this._super.apply(this, arguments);
+ this.$addUrlButton.text((isURL && !isImage) ? _t("Add as document") : _t("Add image"));
+ const warning = isURL && !isImage;
+ this.$urlWarning.toggleClass('d-none', !warning);
+ if (warning) {
+ this.$urlSuccess.addClass('d-none');
+ }
+ },
+ /**
+ * @override
+ */
+ _renderThumbnails: function () {
+ const alreadyLoaded = this.$('.o_existing_attachment_cell[data-loaded="true"]');
+ this._super(...arguments);
+ // Hide images until they're loaded
+ this.$('.o_existing_attachment_cell').addClass('d-none');
+ // Replace images that had been previously loaded if any to prevent scroll resetting to top
+ alreadyLoaded.each((index, el) => {
+ const toReplace = this.$(`.o_existing_attachment_cell[data-id="${el.dataset.id}"], .o_existing_attachment_cell[data-media-id="${el.dataset.mediaId}"]`);
+ if (toReplace.length) {
+ toReplace.replaceWith(el);
+ }
+ });
+ this._toggleOptimized(this.$('input.o_we_show_optimized')[0].checked);
+ // Placeholders have a 3:2 aspect ratio like most photos.
+ const placeholderWidth = 3 / 2 * this.MIN_ROW_HEIGHT;
+ this.$('.o_we_attachment_placeholder').css({
+ flexGrow: placeholderWidth,
+ flexBasis: placeholderWidth,
+ });
+ if (this.needle && ['media-library', 'all'].includes(this.searchService)) {
+ const noMoreImgToLoad = this.libraryMedia.length === this.nbMediaResults;
+ const noLoadMoreButton = noMoreImgToLoad && this.libraryMedia.length <= 15;
+ this.$('.o_load_done_msg').toggleClass('d-none', noLoadMoreButton || !noMoreImgToLoad);
+ this.$('.o_load_more').toggleClass('d-none', noMoreImgToLoad);
+ }
+ },
+ /**
+ * @override
+ */
+ _renderExisting: function (attachments) {
+ if (this.needle && this.searchService !== 'database') {
+ attachments = attachments.slice(0, this.MAX_DB_ATTACHMENTS);
+ }
+ return QWeb.render(this.existingAttachmentsTemplate, {
+ attachments: attachments,
+ libraryMedia: this.libraryMedia,
+ widget: this,
+ });
+ },
+ /**
+ * @private
+ *
+ * @param {boolean} value whether to toggle optimized attachments on or off
+ */
+ _toggleOptimized: function (value) {
+ this.$('.o_we_attachment_optimized').each((i, cell) => cell.style.setProperty('display', value ? null : 'none', 'important'));
+ },
+ /**
+ * @override
+ */
+ _highlightSelected: function () {
+ this._super(...arguments);
+ this.selectedMedia.forEach(media => {
+ this.$(`.o_existing_attachment_cell[data-media-id=${media.id}]`)
+ .addClass("o_we_attachment_selected");
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _onAttachmentImageLoad: async function (ev) {
+ const img = ev.target;
+ const cell = img.closest('.o_existing_attachment_cell');
+ if (!cell) {
+ return;
+ }
+ if (cell.dataset.mediaId && !img.src.startsWith('blob')) {
+ const mediaUrl = img.src;
+ try {
+ const response = await fetch(mediaUrl);
+ if (response.headers.get('content-type') === 'image/svg+xml') {
+ const svg = await response.text();
+ const colorRegex = new RegExp(DEFAULT_PALETTE['1'], 'gi');
+ if (colorRegex.test(svg)) {
+ const fileName = mediaUrl.split('/').pop();
+ const file = new File([svg.replace(colorRegex, getCSSVariableValue('o-color-1'))], fileName, {
+ type: "image/svg+xml",
+ });
+ img.src = URL.createObjectURL(file);
+ const media = this.libraryMedia.find(media => media.id === parseInt(cell.dataset.mediaId));
+ if (media) {
+ media.isDynamicSVG = true;
+ }
+ // We changed the src: wait for the next load event to do the styling
+ return;
+ }
+ }
+ } catch (e) {
+ console.error('CORS is misconfigured on the API server, image will be treated as non-dynamic.');
+ }
+ }
+ let aspectRatio = img.naturalWidth / img.naturalHeight;
+ // Special case for SVGs with no instrinsic sizes on firefox
+ // See https://github.com/whatwg/html/issues/3510#issuecomment-369982529
+ if (img.naturalHeight === 0) {
+ img.width = 1000;
+ // Position fixed so that the image doesn't affect layout while rendering
+ img.style.position = 'fixed';
+ // Make invisible so the image doesn't briefly appear on the screen
+ img.style.opacity = '0';
+ // Image needs to be in the DOM for dimensions to be correct after render
+ const originalParent = img.parentElement;
+ document.body.appendChild(img);
+
+ aspectRatio = img.width / img.height;
+ originalParent.appendChild(img);
+ img.removeAttribute('width');
+ img.style.removeProperty('position');
+ img.style.removeProperty('opacity');
+ }
+ const width = aspectRatio * this.MIN_ROW_HEIGHT;
+ cell.style.flexGrow = width;
+ cell.style.flexBasis = `${width}px`;
+ cell.classList.remove('d-none');
+ cell.classList.add('d-flex');
+ cell.dataset.loaded = 'true';
+ },
+ /**
+ * @override
+ */
+ _onShowOptimizedChange: function (ev) {
+ this._toggleOptimized(ev.target.checked);
+ },
+ /**
+ * @override
+ */
+ _onSearchSelect: function (ev) {
+ const {value} = ev.target;
+ this.searchService = value;
+ this.$('.o_we_search').trigger('input');
+ },
+ /**
+ * @private
+ */
+ _onSearchInput: function (ev) {
+ this.libraryMedia = [];
+ this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ _clear: function (type) {
+ // Not calling _super: we don't want to call the document widget's _clear method on images
+ var allImgClasses = /(^|\s+)(img|img-\S*|o_we_custom_image|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g;
+ this.media.className = this.media.className && this.media.className.replace(allImgClasses, ' ');
+ },
+});
+
+
+/**
+ * Let users choose a document, including uploading a new document in odoo.
+ */
+var DocumentWidget = FileWidget.extend({
+ template: 'wysiwyg.widgets.document',
+ existingAttachmentsTemplate: 'wysiwyg.widgets.document.existing.attachments',
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ options = _.extend({
+ accept: '*/*',
+ mimetypeDomain: [['mimetype', 'not in', this.IMAGE_MIMETYPES]],
+ }, options || {});
+ this._super(parent, media, options);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _updateAddUrlUi: function (emptyValue, isURL, isImage) {
+ this._super.apply(this, arguments);
+ this.$addUrlButton.text((isURL && isImage) ? _t("Add as image") : _t("Add document"));
+ const warning = isURL && isImage;
+ this.$urlWarning.toggleClass('d-none', !warning);
+ if (warning) {
+ this.$urlSuccess.addClass('d-none');
+ }
+ },
+ /**
+ * @override
+ */
+ _getAttachmentsDomain: function (needle) {
+ var domain = this._super.apply(this, arguments);
+ // the assets should not be part of the documents
+ return domain.concat('!', utils.assetsDomain());
+ },
+});
+
+/**
+ * Let users choose a font awesome icon, support all font awesome loaded in the
+ * css files.
+ */
+var IconWidget = SearchableMediaWidget.extend({
+ template: 'wysiwyg.widgets.font-icons',
+ events: _.extend({}, SearchableMediaWidget.prototype.events || {}, {
+ 'click .font-icons-icon': '_onIconClick',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media) {
+ this._super.apply(this, arguments);
+
+ fonts.computeFonts();
+ this.iconsParser = fonts.fontIcons;
+ this.alias = _.flatten(_.map(this.iconsParser, function (data) {
+ return data.alias;
+ }));
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$icons = this.$('.font-icons-icon');
+ var classes = (this.media && this.media.className || '').split(/\s+/);
+ for (var i = 0; i < classes.length; i++) {
+ var cls = classes[i];
+ if (_.contains(this.alias, cls)) {
+ this.selectedIcon = cls;
+ this.initialIcon = cls;
+ this._highlightSelectedIcon();
+ }
+ }
+ // Kept for compat in stable, no longer in use: remove in master
+ this.nonIconClasses = _.without(classes, 'media_iframe_video', this.selectedIcon);
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ var style = this.$media.attr('style') || '';
+ var iconFont = this._getFont(this.selectedIcon) || {base: 'fa', font: ''};
+ if (!this.$media.is('span, i')) {
+ var $span = $('<span/>');
+ $span.data(this.$media.data());
+ this.$media = $span;
+ this.media = this.$media[0];
+ style = style.replace(/\s*width:[^;]+/, '');
+ }
+ this.$media.removeClass(this.initialIcon).addClass([iconFont.base, iconFont.font]);
+ this.$media.attr('style', style || null);
+ return Promise.resolve(this.media);
+ },
+ /**
+ * @override
+ */
+ search: function (needle) {
+ var iconsParser = this.iconsParser;
+ if (needle && needle.length) {
+ iconsParser = [];
+ _.filter(this.iconsParser, function (data) {
+ var cssData = _.filter(data.cssData, function (cssData) {
+ return _.find(cssData.names, function (alias) {
+ return alias.indexOf(needle) >= 0;
+ });
+ });
+ if (cssData.length) {
+ iconsParser.push({
+ base: data.base,
+ cssData: cssData,
+ });
+ }
+ });
+ }
+ this.$('div.font-icons-icons').html(
+ QWeb.render('wysiwyg.widgets.font-icons.icons', {iconsParser: iconsParser, widget: this})
+ );
+ return Promise.resolve();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ var allFaClasses = /(^|\s)(fa|(text-|bg-|fa-)\S*|rounded-circle|rounded|thumbnail|shadow)(?=\s|$)/g;
+ this.media.className = this.media.className && this.media.className.replace(allFaClasses, ' ');
+ },
+ /**
+ * @private
+ */
+ _getFont: function (classNames) {
+ if (!(classNames instanceof Array)) {
+ classNames = (classNames || "").split(/\s+/);
+ }
+ var fontIcon, cssData;
+ for (var k = 0; k < this.iconsParser.length; k++) {
+ fontIcon = this.iconsParser[k];
+ for (var s = 0; s < fontIcon.cssData.length; s++) {
+ cssData = fontIcon.cssData[s];
+ if (_.intersection(classNames, cssData.names).length) {
+ return {
+ base: fontIcon.base,
+ parser: fontIcon.parser,
+ font: cssData.names[0],
+ };
+ }
+ }
+ }
+ return null;
+ },
+ /**
+ * @private
+ */
+ _highlightSelectedIcon: function () {
+ var self = this;
+ this.$icons.removeClass('o_we_attachment_selected');
+ this.$icons.filter(function (i, el) {
+ return _.contains($(el).data('alias').split(','), self.selectedIcon);
+ }).addClass('o_we_attachment_selected');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onIconClick: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.selectedIcon = $(ev.currentTarget).data('id');
+ this._highlightSelectedIcon();
+ this.trigger_up('save_request');
+ },
+});
+
+/**
+ * Let users choose a video, support all summernote video, and embed iframe.
+ */
+var VideoWidget = MediaWidget.extend({
+ template: 'wysiwyg.widgets.video',
+ events: _.extend({}, MediaWidget.prototype.events || {}, {
+ 'change .o_video_dialog_options input': '_onUpdateVideoOption',
+ 'input textarea#o_video_text': '_onVideoCodeInput',
+ 'change textarea#o_video_text': '_onVideoCodeChange',
+ }),
+
+ /**
+ * @constructor
+ */
+ init: function (parent, media, options) {
+ this._super.apply(this, arguments);
+ this.isForBgVideo = !!options.isForBgVideo;
+ this._onVideoCodeInput = _.debounce(this._onVideoCodeInput, 1000);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$content = this.$('.o_video_dialog_iframe');
+
+ if (this.media) {
+ var $media = $(this.media);
+ var src = $media.data('oe-expression') || $media.data('src') || ($media.is('iframe') ? $media.attr('src') : '') || '';
+ this.$('textarea#o_video_text').val(src);
+
+ this.$('input#o_video_autoplay').prop('checked', src.indexOf('autoplay=1') >= 0);
+ this.$('input#o_video_hide_controls').prop('checked', src.indexOf('controls=0') >= 0);
+ this.$('input#o_video_loop').prop('checked', src.indexOf('loop=1') >= 0);
+ this.$('input#o_video_hide_fullscreen').prop('checked', src.indexOf('fs=0') >= 0);
+ this.$('input#o_video_hide_yt_logo').prop('checked', src.indexOf('modestbranding=1') >= 0);
+ this.$('input#o_video_hide_dm_logo').prop('checked', src.indexOf('ui-logo=0') >= 0);
+ this.$('input#o_video_hide_dm_share').prop('checked', src.indexOf('sharing-enable=0') >= 0);
+
+ this._updateVideo();
+ }
+
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ save: function () {
+ this._updateVideo();
+ if (this.isForBgVideo) {
+ return Promise.resolve({bgVideoSrc: this.$content.attr('src')});
+ }
+ if (this.$('.o_video_dialog_iframe').is('iframe')) {
+ 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" allowfullscreen="allowfullscreen"></iframe>' +
+ '</div>'
+ );
+ this.media = this.$media[0];
+ }
+ return Promise.resolve(this.media);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _clear: function () {
+ if (this.media.dataset.src) {
+ try {
+ delete this.media.dataset.src;
+ } catch (e) {
+ this.media.dataset.src = undefined;
+ }
+ }
+ var allVideoClasses = /(^|\s)media_iframe_video(\s|$)/g;
+ var isVideo = this.media.className && this.media.className.match(allVideoClasses);
+ if (isVideo) {
+ this.media.className = this.media.className.replace(allVideoClasses, ' ');
+ this.media.innerHTML = '';
+ }
+ },
+ /**
+ * Creates a video node according to the given URL and options. If not
+ * possible, returns an error code.
+ *
+ * @private
+ * @param {string} url
+ * @param {Object} options
+ * @returns {Object}
+ * $video -> the created video jQuery node
+ * type -> the type of the created video
+ * errorCode -> if defined, either '0' for invalid URL or '1' for
+ * unsupported video provider
+ */
+ _createVideoNode: function (url, options) {
+ options = options || {};
+ const videoData = this._getVideoURLData(url, options);
+ if (videoData.error) {
+ return {errorCode: 0};
+ }
+ if (!videoData.type) {
+ return {errorCode: 1};
+ }
+ const $video = $('<iframe>').width(1280).height(720)
+ .attr('frameborder', 0)
+ .attr('src', videoData.embedURL)
+ .addClass('o_video_dialog_iframe');
+
+ return {$video: $video, type: videoData.type};
+ },
+ /**
+ * Updates the video preview according to video code and enabled options.
+ *
+ * @private
+ */
+ _updateVideo: function () {
+ // Reset the feedback
+ this.$content.empty();
+ this.$('#o_video_form_group').removeClass('o_has_error o_has_success').find('.form-control, .custom-select').removeClass('is-invalid is-valid');
+ this.$('.o_video_dialog_options div').addClass('d-none');
+
+ // Check video code
+ var $textarea = this.$('textarea#o_video_text');
+ var code = $textarea.val().trim();
+ if (!code) {
+ return;
+ }
+
+ // Detect if we have an embed code rather than an URL
+ var embedMatch = code.match(/(src|href)=["']?([^"']+)?/);
+ if (embedMatch && embedMatch[2].length > 0 && embedMatch[2].indexOf('instagram')) {
+ embedMatch[1] = embedMatch[2]; // Instagram embed code is different
+ }
+ var url = embedMatch ? embedMatch[1] : code;
+
+ var query = this._createVideoNode(url, {
+ 'autoplay': this.isForBgVideo || this.$('input#o_video_autoplay').is(':checked'),
+ 'hide_controls': this.isForBgVideo || this.$('input#o_video_hide_controls').is(':checked'),
+ 'loop': this.isForBgVideo || this.$('input#o_video_loop').is(':checked'),
+ 'hide_fullscreen': this.isForBgVideo || this.$('input#o_video_hide_fullscreen').is(':checked'),
+ 'hide_yt_logo': this.isForBgVideo || this.$('input#o_video_hide_yt_logo').is(':checked'),
+ 'hide_dm_logo': this.isForBgVideo || this.$('input#o_video_hide_dm_logo').is(':checked'),
+ 'hide_dm_share': this.isForBgVideo || this.$('input#o_video_hide_dm_share').is(':checked'),
+ });
+
+ var $optBox = this.$('.o_video_dialog_options');
+
+ // Show / Hide preview elements
+ this.$el.find('.o_video_dialog_preview_text, .media_iframe_video_size').add($optBox).toggleClass('d-none', !query.$video);
+ // Toggle validation classes
+ this.$el.find('#o_video_form_group')
+ .toggleClass('o_has_error', !query.$video).find('.form-control, .custom-select').toggleClass('is-invalid', !query.$video)
+ .end()
+ .toggleClass('o_has_success', !!query.$video).find('.form-control, .custom-select').toggleClass('is-valid', !!query.$video);
+
+ // Individually show / hide options base on the video provider
+ $optBox.find('div.o_' + query.type + '_option').removeClass('d-none');
+
+ // Hide the entire options box if no options are available or if the
+ // dialog is opened for a background-video
+ $optBox.toggleClass('d-none', this.isForBgVideo || $optBox.find('div:not(.d-none)').length === 0);
+
+ if (query.type === 'youtube') {
+ // Youtube only: If 'hide controls' is checked, hide 'fullscreen'
+ // and 'youtube logo' options too
+ this.$('input#o_video_hide_fullscreen, input#o_video_hide_yt_logo').closest('div').toggleClass('d-none', this.$('input#o_video_hide_controls').is(':checked'));
+ }
+
+ var $content = query.$video;
+ if (!$content) {
+ switch (query.errorCode) {
+ case 0:
+ $content = $('<div/>', {
+ class: 'alert alert-danger o_video_dialog_iframe mb-2 mt-2',
+ text: _t("The provided url is not valid"),
+ });
+ break;
+ case 1:
+ $content = $('<div/>', {
+ class: 'alert alert-warning o_video_dialog_iframe mb-2 mt-2',
+ text: _t("The provided url does not reference any supported video"),
+ });
+ break;
+ }
+ }
+ this.$content.replaceWith($content);
+ this.$content = $content;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a video option changes -> Updates the video preview.
+ *
+ * @private
+ */
+ _onUpdateVideoOption: function () {
+ this._updateVideo();
+ },
+ /**
+ * Called when the video code (URL / Iframe) change is confirmed -> Updates
+ * the video preview immediately.
+ *
+ * @private
+ */
+ _onVideoCodeChange: function () {
+ this._updateVideo();
+ },
+ /**
+ * Called when the video code (URL / Iframe) changes -> Updates the video
+ * preview (note: this function is automatically debounced).
+ *
+ * @private
+ */
+ _onVideoCodeInput: function () {
+ this._updateVideo();
+ },
+ /**
+ * Parses a URL and returns the provider type and an emebedable URL.
+ *
+ * @private
+ */
+ _getVideoURLData: function (url, options) {
+ if (!url.match(/^(http:\/\/|https:\/\/|\/\/)[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i)) {
+ return {
+ error: true,
+ message: 'The provided url is invalid',
+ };
+ }
+ const regexes = {
+ youtube: /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/,
+ instagram: /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/,
+ vine: /\/\/vine.co\/v\/(.[a-zA-Z0-9]*)/,
+ vimeo: /\/\/(player.)?vimeo.com\/([a-z]*\/)*([0-9]{6,11})[?]?.*/,
+ dailymotion: /.+dailymotion.com\/(video|hub|embed)\/([^_?]+)[^#]*(#video=([^_&]+))?/,
+ youku: /(.*).youku\.com\/(v_show\/id_|embed\/)(.+)/,
+ };
+ const matches = _.mapObject(regexes, regex => url.match(regex));
+ const autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0';
+ const controls = options.hide_controls ? '&controls=0' : '';
+ const loop = options.loop ? '&loop=1' : '';
+
+ let embedURL;
+ let type;
+ if (matches.youtube && matches.youtube[2].length === 11) {
+ const fullscreen = options.hide_fullscreen ? '&fs=0' : '';
+ const ytLoop = loop ? loop + `&playlist=${matches.youtube[2]}` : '';
+ const logo = options.hide_yt_logo ? '&modestbranding=1' : '';
+ embedURL = `//www.youtube${matches.youtube[1] || ''}.com/embed/${matches.youtube[2]}${autoplay}&rel=0${ytLoop}${controls}${fullscreen}${logo}`;
+ type = 'youtube';
+ } else if (matches.instagram && matches.instagram[2].length) {
+ embedURL = `//www.instagram.com/p/${matches.instagram[2]}/embed/`;
+ type = 'instagram';
+ } else if (matches.vine && matches.vine[0].length) {
+ embedURL = `${matches.vine[0]}/embed/simple`;
+ type = 'vine';
+ } else if (matches.vimeo && matches.vimeo[3].length) {
+ const vimeoAutoplay = autoplay.replace('mute', 'muted');
+ embedURL = `//player.vimeo.com/video/${matches.vimeo[3]}${vimeoAutoplay}${loop}`;
+ type = 'vimeo';
+ } else if (matches.dailymotion && matches.dailymotion[2].length) {
+ const videoId = matches.dailymotion[2].replace('video/', '');
+ const logo = options.hide_dm_logo ? '&ui-logo=0' : '';
+ const share = options.hide_dm_share ? '&sharing-enable=0' : '';
+ embedURL = `//www.dailymotion.com/embed/video/${videoId}${autoplay}${controls}${logo}${share}`;
+ type = 'dailymotion';
+ } else if (matches.youku && matches.youku[3].length) {
+ const videoId = matches.youku[3].indexOf('.html?') >= 0 ? matches.youku[3].substring(0, matches.youku[3].indexOf('.html?')) : matches.youku[3];
+ embedURL = `//player.youku.com/embed/${videoId}`;
+ type = 'youku';
+ }
+
+ return {type: type, embedURL: embedURL};
+ },
+});
+
+return {
+ MediaWidget: MediaWidget,
+ SearchableMediaWidget: SearchableMediaWidget,
+ FileWidget: FileWidget,
+ ImageWidget: ImageWidget,
+ DocumentWidget: DocumentWidget,
+ 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 00000000..0832aa45
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media_dialog.js
@@ -0,0 +1,279 @@
+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;
+
+/**
+ * Lets the user select a media. The media can be existing or newly uploaded.
+ *
+ * The media can be one of the following types: image, document, video or
+ * font awesome icon (only existing icons).
+ *
+ * The user may change a media into another one depending on the given options.
+ */
+var MediaDialog = Dialog.extend({
+ template: 'wysiwyg.widgets.media',
+ xmlDependencies: Dialog.prototype.xmlDependencies.concat(
+ ['/web_editor/static/src/xml/wysiwyg.xml']
+ ),
+ events: _.extend({}, Dialog.prototype.events, {
+ 'click #editor-media-image-tab': '_onClickImageTab',
+ 'click #editor-media-document-tab': '_onClickDocumentTab',
+ 'click #editor-media-icon-tab': '_onClickIconTab',
+ 'click #editor-media-video-tab': '_onClickVideoTab',
+ }),
+ custom_events: _.extend({}, Dialog.prototype.custom_events || {}, {
+ save_request: '_onSaveRequest',
+ show_parent_dialog_request: '_onShowRequest',
+ hide_parent_dialog_request: '_onHideRequest',
+ }),
+
+ /**
+ * @constructor
+ * @param {Element} media
+ */
+ init: function (parent, options, media) {
+ var $media = $(media);
+ media = $media[0];
+ this.media = media;
+
+ options = _.extend({}, options);
+ var onlyImages = options.onlyImages || this.multiImages || (media && ($media.parent().data('oeField') === 'image' || $media.parent().data('oeType') === 'image'));
+ options.noDocuments = onlyImages || options.noDocuments;
+ options.noIcons = onlyImages || options.noIcons;
+ options.noVideos = onlyImages || options.noVideos;
+
+ 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);
+ },
+ });
+
+ if (!options.noImages) {
+ this.imageWidget = new MediaModules.ImageWidget(this, media, options);
+ }
+ if (!options.noDocuments) {
+ this.documentWidget = new MediaModules.DocumentWidget(this, media, options);
+ }
+ if (!options.noIcons) {
+ this.iconWidget = new MediaModules.IconWidget(this, media, options);
+ }
+ if (!options.noVideos) {
+ this.videoWidget = new MediaModules.VideoWidget(this, media, options);
+ }
+
+ if (this.imageWidget && $media.is('img')) {
+ this.activeWidget = this.imageWidget;
+ } else if (this.documentWidget && $media.is('a.o_image')) {
+ this.activeWidget = this.documentWidget;
+ } else if (this.videoWidget && $media.is('.media_iframe_video, .o_bg_video_iframe')) {
+ this.activeWidget = this.videoWidget;
+ } else if (this.iconWidget && $media.is('span, i')) {
+ this.activeWidget = this.iconWidget;
+ } else {
+ this.activeWidget = [this.imageWidget, this.documentWidget, this.videoWidget, this.iconWidget].find(w => !!w);
+ }
+ this.initiallyActiveWidget = this.activeWidget;
+ },
+ /**
+ * Adds the appropriate class to the current modal and appends the media
+ * widgets to their respective tabs.
+ *
+ * @override
+ */
+ start: function () {
+ var promises = [this._super.apply(this, arguments)];
+ this.$modal.find('.modal-dialog').addClass('o_select_media_dialog');
+
+ if (this.imageWidget) {
+ promises.push(this.imageWidget.appendTo(this.$("#editor-media-image")));
+ }
+ if (this.documentWidget) {
+ promises.push(this.documentWidget.appendTo(this.$("#editor-media-document")));
+ }
+ if (this.iconWidget) {
+ promises.push(this.iconWidget.appendTo(this.$("#editor-media-icon")));
+ }
+ if (this.videoWidget) {
+ promises.push(this.videoWidget.appendTo(this.$("#editor-media-video")));
+ }
+
+ this.opened(() => this.$('input.o_we_search:visible:first').focus());
+
+ return Promise.all(promises);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Returns whether the document widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isDocumentActive: function () {
+ return this.activeWidget === this.documentWidget;
+ },
+ /**
+ * Returns whether the icon widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isIconActive: function () {
+ return this.activeWidget === this.iconWidget;
+ },
+ /**
+ * Returns whether the image widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isImageActive: function () {
+ return this.activeWidget === this.imageWidget;
+ },
+ /**
+ * Returns whether the video widget is currently active.
+ *
+ * @returns {boolean}
+ */
+ isVideoActive: function () {
+ return this.activeWidget === this.videoWidget;
+ },
+ /**
+ * Saves the currently selected media from the currently active widget.
+ *
+ * The save event data `final_data` will be one Element in general, but it
+ * will be an Array of Element if `multiImages` is set.
+ *
+ * @override
+ */
+ save: function () {
+ var self = this;
+ var _super = this._super;
+ var args = arguments;
+ return this.activeWidget.save().then(function (data) {
+ if (self.activeWidget !== self.initiallyActiveWidget) {
+ self._clearWidgets();
+ }
+ // Restore classes if the media was replaced (when changing type)
+ if (self.media !== data) {
+ var oldClasses = self.media && _.toArray(self.media.classList);
+ if (oldClasses) {
+ data.className = _.union(_.toArray(data.classList), oldClasses).join(' ');
+ }
+ }
+ self.final_data = data;
+ _super.apply(self, args);
+ $(data).trigger('content_changed');
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Call clear on all the widgets except the activeWidget.
+ * We clear because every widgets are modifying the "media" element.
+ * All widget have the responsibility to clear a previous element that
+ * was created from them.
+ */
+ _clearWidgets: function () {
+ [ this.imageWidget,
+ this.documentWidget,
+ this.iconWidget,
+ this.videoWidget
+ ].forEach( (widget) => {
+ if (widget !== this.activeWidget) {
+ widget && widget.clear();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Sets the document widget as the active widget.
+ *
+ * @private
+ */
+ _onClickDocumentTab: function () {
+ this.activeWidget = this.documentWidget;
+ },
+ /**
+ * Sets the icon widget as the active widget.
+ *
+ * @private
+ */
+ _onClickIconTab: function () {
+ this.activeWidget = this.iconWidget;
+ },
+ /**
+ * Sets the image widget as the active widget.
+ *
+ * @private
+ */
+ _onClickImageTab: function () {
+ this.activeWidget = this.imageWidget;
+ },
+ /**
+ * Sets the video widget as the active widget.
+ *
+ * @private
+ */
+ _onClickVideoTab: function () {
+ this.activeWidget = this.videoWidget;
+ },
+ /**
+ * Handles hide request from child widgets.
+ *
+ * This is for usability, to allow hiding the modal for example when another
+ * smaller modal would be displayed on top.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onHideRequest: function (ev) {
+ this.$modal.addClass('d-none');
+ },
+ /**
+ * Handles save request from the child widgets.
+ *
+ * This is for usability, to allow the user to save from other ways than
+ * click on the modal button, such as double clicking a media to select it.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSaveRequest: function (ev) {
+ ev.stopPropagation();
+ this.save();
+ },
+ /**
+ * Handles show request from the child widgets.
+ *
+ * This is for usability, it is the counterpart of @see _onHideRequest.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onShowRequest: function (ev) {
+ this.$modal.removeClass('d-none');
+ },
+});
+
+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 00000000..64a9dc06
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/widgets/widgets.js
@@ -0,0 +1,29 @@
+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 ImageCropWidget = require('wysiwyg.widgets.ImageCropWidget');
+const {ColorpickerDialog} = require('web.Colorpicker');
+
+var media = require('wysiwyg.widgets.media');
+
+return {
+ Dialog: Dialog,
+ AltDialog: AltDialog,
+ MediaDialog: MediaDialog,
+ LinkDialog: LinkDialog,
+ ImageCropWidget: ImageCropWidget,
+ ColorpickerDialog: ColorpickerDialog,
+
+ MediaWidget: media.MediaWidget,
+ SearchableMediaWidget: media.SearchableMediaWidget,
+ FileWidget: media.FileWidget,
+ ImageWidget: media.ImageWidget,
+ DocumentWidget: media.DocumentWidget,
+ 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 00000000..6a1924e1
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js
@@ -0,0 +1,274 @@
+odoo.define('web_editor.wysiwyg', function (require) {
+'use strict';
+var Widget = require('web.Widget');
+var SummernoteManager = require('web_editor.rte.summernote');
+var summernoteCustomColors = require('web_editor.rte.summernote_custom_colors');
+var id = 0;
+
+// core.bus
+// media_dialog_demand
+var Wysiwyg = Widget.extend({
+ xmlDependencies: [
+ ],
+ defaultOptions: {
+ 'focus': false,
+ 'toolbar': [
+ ['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',
+ 'colors': summernoteCustomColors,
+ recordInfo: {
+ context: {},
+ },
+ },
+ /**
+ * @options {Object} options
+ * @options {Object} options.recordInfo
+ * @options {Object} options.recordInfo.context
+ * @options {String} [options.recordInfo.context]
+ * @options {integer} [options.recordInfo.res_id]
+ * @options {String} [options.recordInfo.data_res_model]
+ * @options {integer} [options.recordInfo.data_res_id]
+ * @see _onGetRecordInfo
+ * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js
+ * @options {Object} options.attachments
+ * @see _onGetRecordInfo
+ * @see _getAttachmentsDomain in /wysiwyg/widgets/media.js (for attachmentIDs)
+ * @options {function} options.generateOptions
+ * called with the summernote configuration object used before sending to summernote
+ * @see _editorOptions
+ **/
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.id = ++id;
+ this.options = options;
+ },
+ /**
+ * Load assets and color picker template then call summernote API
+ * and replace $el by the summernote editable node.
+ *
+ * @override
+ **/
+ willStart: function () {
+ this._summernoteManager = new SummernoteManager(this);
+ this.$target = this.$el;
+ return this._super();
+ },
+ /**
+ *
+ * @override
+ */
+ start: function () {
+ this.$target.wrap('<odoo-wysiwyg-container>');
+ this.$el = this.$target.parent();
+ var options = this._editorOptions();
+ this.$target.summernote(options);
+ this.$editor = this.$('.note-editable:first');
+ this.$editor.data('wysiwyg', this);
+ this.$editor.data('oe-model', options.recordInfo.res_model);
+ this.$editor.data('oe-id', options.recordInfo.res_id);
+ $(document).on('mousedown', this._blur);
+ this._value = this.$target.html() || this.$target.val();
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ $(document).off('mousedown', this._blur);
+ if (this.$target && this.$target.is('textarea') && this.$target.next('.note-editor').length) {
+ this.$target.summernote('destroy');
+ }
+ this._super();
+ },
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+ /**
+ * Return the editable area.
+ *
+ * @returns {jQuery}
+ */
+ getEditable: function () {
+ return this.$editor;
+ },
+ /**
+ * Return true if the content has changed.
+ *
+ * @returns {Boolean}
+ */
+ isDirty: function () {
+ return this._value !== (this.$editor.html() || this.$editor.val());
+ },
+ /**
+ * Set the focus on the element.
+ */
+ focus: function () {
+ console.log('focus');
+ },
+ /**
+ * Get the value of the editable element.
+ *
+ * @param {object} [options]
+ * @param {jQueryElement} [options.$layout]
+ * @returns {String}
+ */
+ getValue: function (options) {
+ var $editable = options && options.$layout || this.$editor.clone();
+ $editable.find('[contenteditable]').removeAttr('contenteditable');
+ $editable.find('[class=""]').removeAttr('class');
+ $editable.find('[style=""]').removeAttr('style');
+ $editable.find('[title=""]').removeAttr('title');
+ $editable.find('[alt=""]').removeAttr('alt');
+ $editable.find('[data-original-title=""]').removeAttr('data-original-title');
+ if (!options || !options['style-inline']) {
+ $editable.find('a.o_image, span.fa, i.fa').html('');
+ }
+ $editable.find('[aria-describedby]').removeAttr('aria-describedby').removeAttr('data-original-title');
+ return $editable.html();
+ },
+ /**
+ * 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 (options) {
+ var isDirty = this.isDirty();
+ var html = this.getValue(options);
+ if (this.$target.is('textarea')) {
+ this.$target.val(html);
+ } else {
+ this.$target.html(html);
+ }
+ return Promise.resolve({isDirty:isDirty, html:html});
+ },
+ /**
+ * Create/Update cropped attachments.
+ *
+ * @param {jQuery} $editable
+ * @returns {Promise}
+ */
+ saveModifiedImages: function ($editable) {
+ return this._summernoteManager.saveModifiedImages($editable);
+ },
+ /**
+ * @param {String} value
+ * @param {Object} options
+ * @param {Boolean} [options.notifyChange]
+ * @returns {String}
+ */
+ setValue: function (value, options) {
+ if (this.$editor.is('textarea')) {
+ this.$target.val(value);
+ } else {
+ this.$target.html(value);
+ }
+ this.$editor.html(value);
+ },
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+ _editorOptions: function () {
+ var self = this;
+ var options = Object.assign({}, $.summernote.options, this.defaultOptions, this.options);
+ if (this.options.generateOptions) {
+ options = this.options.generateOptions(options);
+ }
+ options.airPopover = options.toolbar;
+ options.onChange = function (html, $editable) {
+ $editable.trigger('content_changed');
+ self.trigger_up('wysiwyg_change');
+ };
+ options.onUpload = function (attachments) {
+ self.trigger_up('wysiwyg_attachment', attachments);
+ };
+ options.onFocus = function () {
+ self.trigger_up('wysiwyg_focus');
+ };
+ options.onBlur = function () {
+ self.trigger_up('wysiwyg_blur');
+ };
+ return options;
+ },
+});
+//--------------------------------------------------------------------------
+// Public helper
+//--------------------------------------------------------------------------
+/**
+ * @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.core.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.core.range.create(startNode, startOffset, endNode, endOffset).select();
+ } else {
+ $.summernote.core.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);
+ }
+};
+return Wysiwyg;
+});
+odoo.define('web_editor.widget', function (require) {
+'use strict';
+ return {
+ Dialog: require('wysiwyg.widgets.Dialog'),
+ MediaDialog: require('wysiwyg.widgets.MediaDialog'),
+ LinkDialog: require('wysiwyg.widgets.LinkDialog'),
+ };
+});
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 00000000..f56f5b26
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_iframe.js
@@ -0,0 +1,132 @@
+odoo.define('web_editor.wysiwyg.iframe', function (require) {
+'use strict';
+
+var Wysiwyg = require('web_editor.wysiwyg');
+var ajax = require('web.ajax');
+var core = require('web.core');
+var config = require('web.config');
+
+var qweb = core.qweb;
+var promiseCommon;
+var promiseWysiwyg;
+
+
+/**
+ * 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) {
+ this._onUpdateIframeId = 'onLoad_' + this.id;
+ }
+ this.__extraAssetsForIframe = [];
+ },
+ /**
+ * Load assets to inject into iframe.
+ *
+ * @override
+ **/
+ willStart: function () {
+ if (!this.options.inIframe) {
+ return this._super();
+ }
+
+ var defAsset;
+ if (this.options.iframeCssAssets) {
+ defAsset = ajax.loadAsset(this.options.iframeCssAssets);
+ } else {
+ defAsset = Promise.resolve({
+ cssLibs: [],
+ cssContents: []
+ });
+ }
+
+ promiseWysiwyg = promiseWysiwyg || ajax.loadAsset('web_editor.wysiwyg_iframe_editor_assets');
+ this.defAsset = Promise.all([promiseWysiwyg, defAsset]);
+
+ this.$target = this.$el;
+ return this.defAsset
+ .then(this._loadIframe.bind(this))
+ .then(this._super.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Create iframe, inject css and create a link with the content,
+ * then inject the target inside.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _loadIframe: function () {
+ var self = this;
+ this.$iframe = $('<iframe class="wysiwyg_iframe">').css({
+ 'min-height': '55vh',
+ width: '100%'
+ });
+ var avoidDoubleLoad = 0; // this bug only appears on some configurations.
+
+ // resolve promise on load
+ var def = new Promise(function (resolve) {
+ window.top[self._onUpdateIframeId] = function (Editor, _avoidDoubleLoad) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg iframe double load detected');
+ return;
+ }
+ delete window.top[self._onUpdateIframeId];
+ var $iframeTarget = self.$iframe.contents().find('#iframe_target');
+ $iframeTarget.attr("isMobile", config.device.isMobile);
+ $iframeTarget.find('.o_editable').html(self.$target.val());
+ self.options.toolbarHandler = $('#web_editor-top-edit', self.$iframe[0].contentWindow.document);
+ $(qweb.render('web_editor.FieldTextHtml.fullscreen'))
+ .appendTo(self.options.toolbarHandler)
+ .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)
+ });
+ self.Editor = Editor;
+ resolve();
+ };
+ });
+ this.$iframe.data('loadDef', def); // for unit test
+
+ // inject content in iframe
+
+ this.$iframe.on('load', function onLoad (ev) {
+ var _avoidDoubleLoad = ++avoidDoubleLoad;
+ self.defAsset.then(function (assets) {
+ if (_avoidDoubleLoad !== avoidDoubleLoad) {
+ console.warn('Wysiwyg immediate iframe double load detected');
+ return;
+ }
+
+ var iframeContent = qweb.render('wysiwyg.iframeContent', {
+ assets: assets.concat(self.__extraAssetsForIframe),
+ updateIframeId: self._onUpdateIframeId,
+ avoidDoubleLoad: _avoidDoubleLoad
+ });
+ self.$iframe[0].contentWindow.document
+ .open("text/html", "replace")
+ .write(iframeContent);
+ });
+ });
+
+ this.$iframe.insertAfter(this.$target);
+
+ return def;
+ },
+});
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js
new file mode 100644
index 00000000..73801dc9
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_snippets.js
@@ -0,0 +1,56 @@
+odoo.define('web_editor.wysiwyg.snippets', function (require) {
+'use strict';
+var editor = require('web_editor.editor');
+var Wysiwyg = require('web_editor.wysiwyg');
+
+
+Wysiwyg.include({
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.Editor = editor.Class;
+ if (!this.options.toolbarHandler) {
+ this.options.toolbarHandler = $('#web_editor-top-edit');
+ }
+ },
+ start: async function () {
+ if (this.options.snippets) {
+ var self = this;
+ this.editor = new (this.Editor)(this, this.options);
+ this.$editor = this.editor.rte.editable();
+ const $body = this.$editor[0] ? this.$editor[0].ownerDocument.body : document.body;
+ await this.editor.prependTo($body);
+ this._relocateEditorBar();
+ this.$el.on('content_changed', function (e) {
+ self.trigger_up('wysiwyg_change');
+ });
+ } else {
+ return this._super();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _relocateEditorBar: function () {
+ if (!this.options.toolbarHandler.length) {
+ this.options.toolbarHandler = $('.o_we_snippet_text_tools');
+ }
+ this.options.toolbarHandler.append(this.editor.$el);
+
+ // TODO the next four lines are a huge hack: since the editor.$el
+ // is repositioned, the snippetsMenu elements are not at the
+ // correct position anymore if it was repositioned outside of it...
+ // the whole logic has to be refactored... hopefully not needed anymore
+ // with editor team changes
+ if (this.editor.snippetsMenu && !this.editor.snippetsMenu.$el.has(this.options.toolbarHandler).length) {
+ this.editor.snippetsMenu.$el.insertAfter(this.options.toolbarHandler);
+ this.editor.snippetsMenu.$snippetEditorArea.insertAfter(this.editor.snippetsMenu.$el);
+ }
+ },
+});
+
+});
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg_translate_attributes.js
diff --git a/addons/web_editor/static/src/scss/13_0_color_system_support_primary_variables.scss b/addons/web_editor/static/src/scss/13_0_color_system_support_primary_variables.scss
new file mode 100644
index 00000000..4fb5d42c
--- /dev/null
+++ b/addons/web_editor/static/src/scss/13_0_color_system_support_primary_variables.scss
@@ -0,0 +1 @@
+$o-support-13-0-color-system: true;
diff --git a/addons/web_editor/static/src/scss/bootstrap_overridden.scss b/addons/web_editor/static/src/scss/bootstrap_overridden.scss
new file mode 100644
index 00000000..f48d9bda
--- /dev/null
+++ b/addons/web_editor/static/src/scss/bootstrap_overridden.scss
@@ -0,0 +1,76 @@
+// Use auto threshold for yiq colors
+$yiq-contrasted-threshold: false !default;
+
+// Automatically update bootstrap colors map (unused by BS itself)
+$colors: () !default;
+@each $name, $color in $o-color-palette {
+ $colors: map-merge(('#{$name}': o-color($color)), $colors);
+}
+
+$o-btn-bg-colors: () !default;
+$o-btn-bg-colors: map-merge((
+ 'primary': o-color('o-cc1-btn-primary'),
+ 'secondary': o-color('o-cc1-btn-secondary'),
+), $o-btn-bg-colors);
+$o-btn-border-colors: () !default;
+$o-btn-border-colors: map-merge((
+ 'primary': o-color('o-cc1-btn-primary-border'),
+ 'secondary': o-color('o-cc1-btn-secondary-border'),
+), $o-btn-border-colors);
+
+// Automatically extend bootstrap to create theme background/text/button classes
+$theme-colors: () !default;
+@each $name, $color in $o-theme-color-palette {
+ $theme-colors: map-merge(('#{$name}': o-color($color)), $theme-colors);
+}
+
+// Automatically extend bootstrap gray palette (the theme palette is supposed to
+// at least declare white and black)
+$grays: () !default;
+@each $name, $color in $o-gray-color-palette {
+ $grays: map-merge(('#{$name}': o-color($color)), $grays);
+}
+
+// Bootstrap use standard variables to define individual colors which are then
+// placed into a map which is then used to get the value of each individual
+// color. As BS4 allows to extend the map a priori to define our own colors,
+// it does not take care of making the standard variables match the values in
+// the user's map. The problem is that, at least for grays, bootstrap uses the
+// standard variables in its _variables.scss file, so if:
+//
+// User file:
+// $grays: (
+// '100': blue,
+// );
+//
+// BS4:
+// $gray-100: gray !default;
+// $grays: () !default;
+// $grays: map-merge((
+// '100': $gray-100,
+// ), $grays);
+//
+// -> Here gray('100') is blue but $gray-100 is still gray... so BS4 is not
+// correctly generated as BS4 uses $gray-100 in _variables.scss
+$primary: theme-color('primary') !default;
+$secondary: theme-color('secondary') !default;
+$success: theme-color('success') !default;
+$info: theme-color('info') !default;
+$warning: theme-color('warning') !default;
+$danger: theme-color('danger') !default;
+$light: theme-color('light') !default;
+$dark: theme-color('dark') !default;
+
+$white: gray('white') !default;
+$gray-100: gray('100') !default;
+$gray-200: gray('200') !default;
+$gray-300: gray('300') !default;
+$gray-400: gray('400') !default;
+$gray-500: gray('500') !default;
+$gray-600: gray('600') !default;
+$gray-700: gray('700') !default;
+$gray-800: gray('800') !default;
+$gray-900: gray('900') !default;
+$black: gray('black') !default;
+
+$o-color-system-initialized: true;
diff --git a/addons/web_editor/static/src/scss/bootstrap_overridden_backend.scss b/addons/web_editor/static/src/scss/bootstrap_overridden_backend.scss
new file mode 100644
index 00000000..ef2d6cf0
--- /dev/null
+++ b/addons/web_editor/static/src/scss/bootstrap_overridden_backend.scss
@@ -0,0 +1,14 @@
+
+$o-theme-color-palette: map-remove($o-theme-color-palette, 'primary', 'secondary', 'success', 'info', 'warning', 'danger', 'light', 'dark');
+$o-gray-color-palette: map-remove($o-gray-color-palette, '100', '200', '300', '400', '500', '600', '700', '800', '900');
+
+$o-btn-bg-colors: () !default;
+$o-btn-bg-colors: map-merge((
+ 'primary': null,
+ 'secondary': null,
+), $o-btn-bg-colors);
+$o-btn-border-colors: () !default;
+$o-btn-border-colors: map-merge((
+ 'primary': null,
+ 'secondary': null,
+), $o-btn-border-colors);
diff --git a/addons/web_editor/static/src/scss/secondary_variables.scss b/addons/web_editor/static/src/scss/secondary_variables.scss
new file mode 100644
index 00000000..815e2c72
--- /dev/null
+++ b/addons/web_editor/static/src/scss/secondary_variables.scss
@@ -0,0 +1,137 @@
+
+//------------------------------------------------------------------------------
+// Colors
+//------------------------------------------------------------------------------
+
+// Color combinations
+$o-color-combinations: o-safe-nth($o-color-combinations-presets, $o-color-combinations-preset-number, ()) !default;
+$-combination-additions: ();
+@for $index from 1 through length($o-color-combinations) {
+ $combination: map-merge($o-base-color-combination, nth($o-color-combinations, $index));
+
+ @each $element, $color in $combination {
+ $-combination-additions: map-merge($-combination-additions, (
+ 'o-cc#{$index}-#{$element}': $color,
+ ));
+ }
+}
+
+// Colors
+$o-color-palette: o-safe-nth($o-color-palettes, $o-color-palette-number, ()) !default;
+// Original color palette can contain override of the default combinations (so keep 'null' values for this merge)
+$o-color-palette: map-merge($-combination-additions, $o-color-palette);
+$o-color-palette: map-merge($o-base-color-palette, o-map-omit($o-color-palette));
+
+// Theme colors
+$o-theme-color-palette: o-safe-nth($o-theme-color-palettes, $o-theme-color-palette-number, ()) !default;
+@if not $o-support-13-0-color-system {
+ $o-theme-color-palette: map-remove($o-theme-color-palette, 'alpha', 'beta', 'gamma', 'delta', 'epsilon');
+}
+$-main-color: map-get($o-color-palette, 'o-color-1');
+$-main-color-lightness: lightness($-main-color);
+$o-theme-color-palette: map-merge((
+ // color 1 and 2 are used to override primary and secondary BS4
+ // colors by default, so that theme colors affect the default Odoo layouts
+ 'primary': $-main-color,
+ 'secondary': map-get($o-color-palette, 'o-color-2'),
+
+ // BS light and dark colors are not used for any BS component, just
+ // for color utilities. By default, we set them to a very light and
+ // very dark version of a desaturate version of the main color
+ 'light': lighten(desaturate($-main-color, 80%), min(70%, max(0%, 97% - $-main-color-lightness))), // Does not increase over 97% lightness
+ 'dark': darken(desaturate($-main-color, 80%), min(70%, max(0%, $-main-color-lightness - 10%))), // Does not lower under 10% lightness
+), o-map-omit($o-theme-color-palette));
+$o-theme-color-palette: map-merge($o-base-theme-color-palette, o-map-omit($o-theme-color-palette));
+
+// Gray colors
+// Extend grays with transparent ones (for some reason, BS4 create black-50 and
+// white-50 but does not allow overridding that with variables).
+$o-gray-color-palette: o-safe-nth($o-gray-color-palettes, $o-gray-color-palette-number, ()) !default;
+$o-gray-color-palette: map-merge($o-transparent-grays, o-map-omit($o-gray-color-palette));
+$o-gray-color-palette: map-merge($o-base-gray-color-palette, o-map-omit($o-gray-color-palette));
+
+$o-color-system-initialized: false;
+
+// Returns:
+// - true if the given name is a css color or null
+// - false if a potential valid color name
+// - throws an error if the given arg cannot reference a color
+@function check-color-identifier-type($name) {
+ $-type: type-of($name);
+ @if $-type == 'color' or $-type == 'null' {
+ @return true;
+ } @else if $-type != 'string' {
+ @error "Color name '#{$name}' is of unsupported type '#{$-type}'";
+ }
+ @return false;
+}
+@function use-cc-bg($name) {
+ @if type-of($name) == 'number' {
+ // Preset number, let's return the background color of the related
+ // preset.
+ @return 'o-cc#{$name}-bg';
+ }
+ @return $name;
+}
+// Looks up for the color related to the given name in the related odoo palettes
+// following redirection a maximum number of time (by default none).
+@function o-related-color($name, $max-recursions: 0, $original-name: $name, $use-cc-bg: false) {
+ @if $use-cc-bg {
+ $name: use-cc-bg($name);
+ } @else if type-of($name) == 'number' {
+ @return $name;
+ }
+
+ @if $max-recursions < 0 or check-color-identifier-type($name) {
+ @return $name;
+ }
+
+ $-value: null;
+ @if map-has-key($o-color-palette, $name) {
+ $-value: map-get($o-color-palette, $name);
+ } @else if map-has-key($o-theme-color-palette, $name) {
+ $-value: map-get($o-theme-color-palette, $name);
+ } @else if map-has-key($o-gray-color-palette, $name) {
+ $-value: map-get($o-gray-color-palette, $name);
+ }
+ @return o-related-color($-value, $max-recursions - 1, $original-name);
+}
+// Function which allows to retrieve a color value from a name, the color being
+// either in $theme-colors, $grays or $colors maps. If those maps are not
+// initialized yet, it will look up the color in the related odoo palettes.
+@function o-color($name) {
+ $name: use-cc-bg($name);
+
+ @if check-color-identifier-type($name) {
+ @return $name;
+ }
+
+ // When the system is initialized, it means that the bootstrap maps have
+ // been configured and contain a direct mapping between color name -> css
+ // value. We can thus search in those.
+ @if $o-color-system-initialized {
+ @if map-has-key($colors, $name) {
+ @return color($name);
+ }
+ @if map-has-key($theme-colors, $name) {
+ @return theme-color($name);
+ }
+ @if map-has-key($grays, $name) {
+ @return gray($name);
+ }
+ }
+
+ // If not initialized, search the css color value in selected color palettes
+ @return o-related-color($name, $max-recursions: 10, $use-cc-bg: true);
+}
+
+// Same as 'increase-contrast' except that the color is not changed if the given
+// related color name is part of the given exclusion list (default to a global
+// exclusion list which can be extended by other apps).
+$o-we-auto-contrast-exclusions: () !default;
+@function auto-contrast($color1, $color2, $color1-name, $exclude: $o-we-auto-contrast-exclusions) {
+ @if index($exclude, $color1-name) {
+ @return $color1;
+ }
+ @return increase-contrast($color1, $color2);
+}
diff --git a/addons/web_editor/static/src/scss/web_editor.backend.scss b/addons/web_editor/static/src/scss/web_editor.backend.scss
new file mode 100644
index 00000000..370baed9
--- /dev/null
+++ b/addons/web_editor/static/src/scss/web_editor.backend.scss
@@ -0,0 +1,69 @@
+.oe_form_field_html {
+ position: relative;
+ word-wrap: break-word;
+
+ .note-editable {
+ min-height: 330px;
+ font: inherit !important;
+ font-family: inherit !important;
+ line-height: inherit !important;
+ color: inherit !important;
+ overflow: visible;
+
+ p, div {
+ font-family: 'Lucida Grande', Helvetica, Verdana, Arial, sans-serif;
+ font-size: 13px;
+ }
+ }
+ ul > li > p, p {
+ margin: 0px;
+ }
+ > iframe {
+ display: block;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ ul > li > p {
+ margin: 0px;
+ }
+ min-height: 300px;
+ min-height: -webkit-calc(100vh - 170px);
+ min-height: calc(100vh - 170px);
+
+ &.o_readonly {
+ border: none;
+ }
+ }
+ .rounded {
+ border-radius: .25rem !important;
+ }
+ table.table.table-bordered {
+ table-layout: fixed;
+ }
+}
+
+.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;
+ }
+ > :not(.modal):not(.modal-backdrop) {
+ 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;
+ }
+}
diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss
new file mode 100644
index 00000000..5b261c2f
--- /dev/null
+++ b/addons/web_editor/static/src/scss/web_editor.common.scss
@@ -0,0 +1,782 @@
+///
+/// This file regroups basic style rules for web_editor enable page edition and backend utils.
+///
+
+:root {
+ @each $color, $value in $grays {
+ @include print-variable($color, $value);
+ }
+
+ // Most of the keys of the color combination color should be null. We have
+ // to indicate their fallback values.
+ @for $index from 1 through length($o-color-combinations) {
+ $-bg-color: o-color(color('o-cc#{$index}-bg'));
+
+ $-text: color('o-cc#{$index}-text') or color-yiq(o-color('o-cc#{$index}-bg'));
+ $-headings: color('o-cc#{$index}-headings') or $-text;
+ $-h2: color('o-cc#{$index}-h2') or $-headings;
+ $-h3: color('o-cc#{$index}-h3') or $-h2;
+ $-h4: color('o-cc#{$index}-h4') or $-h3;
+ $-h5: color('o-cc#{$index}-h5') or $-h4;
+ $-h6: color('o-cc#{$index}-h6') or $-h5;
+
+ @if not color('o-cc#{$index}-text') {
+ @include print-variable('o-cc#{$index}-text', $-text);
+ }
+ @if not color('o-cc#{$index}-headings') {
+ @include print-variable('o-cc#{$index}-headings', $-headings);
+ }
+ @if not color('o-cc#{$index}-h2') {
+ @include print-variable('o-cc#{$index}-h2', $-h2);
+ }
+ @if not color('o-cc#{$index}-h3') {
+ @include print-variable('o-cc#{$index}-h3', $-h3);
+ }
+ @if not color('o-cc#{$index}-h4') {
+ @include print-variable('o-cc#{$index}-h4', $-h4);
+ }
+ @if not color('o-cc#{$index}-h5') {
+ @include print-variable('o-cc#{$index}-h5', $-h5);
+ }
+ @if not color('o-cc#{$index}-h6') {
+ @include print-variable('o-cc#{$index}-h6', $-h6);
+ }
+
+ $-link: color('o-cc#{$index}-link');
+ $-link-color: if($-link, o-color($-link), theme-color('primary'));
+ @include print-variable('o-cc#{$index}-link', auto-contrast($-link-color, $-bg-color, 'o-cc#{$index}-link'));
+
+ $-btn-primary: color('o-cc#{$index}-btn-primary');
+ @if not $-btn-primary {
+ @include print-variable('o-cc#{$index}-btn-primary', theme-color('primary'));
+ }
+ @if not color('o-cc#{$index}-btn-primary-border') {
+ @include print-variable('o-cc#{$index}-btn-primary-border', $-btn-primary or theme-color('primary'));
+ }
+
+ $-btn-secondary: color('o-cc#{$index}-btn-secondary');
+ @if not $-btn-secondary {
+ @include print-variable('o-cc#{$index}-btn-secondary', theme-color('secondary'));
+ }
+ @if not color('o-cc#{$index}-btn-secondary-border') {
+ @include print-variable('o-cc#{$index}-btn-secondary-border', $-btn-secondary or theme-color('secondary'));
+ }
+ }
+}
+
+html, body {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+
+.css_non_editable_mode_hidden {
+ display: none !important;
+}
+.editor_enable .css_editable_mode_hidden {
+ display: none !important;
+}
+.note-toolbar {
+ margin-left: 0 !important;
+}
+.note-popover .popover > .arrow {
+ display: none;
+}
+
+.note-popover .popover, .note-editor {
+ .dropdown-menu .dropdown-item {
+ > i {
+ visibility: hidden;
+ }
+ &.checked > i {
+ visibility: visible;
+ }
+ }
+}
+
+/* ----- GENERIC LAYOUTING HELPERS ---- */
+/* table */
+#wrapwrap, .o_editable {
+ // Only style editor-made tables (shop/portal/... tables are not supposed to
+ // use table-bordered...)
+ table.table.table-bordered {
+ table-layout: fixed;
+ td {
+ min-width: 20px;
+ }
+ }
+ @include media-breakpoint-down(sm) {
+ .table-responsive > table.table {
+ table-layout: auto;
+ }
+ }
+}
+
+// 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;
+ }
+ }
+ }
+}
+ol > li.o_indent, ul > li.o_indent {
+ margin-left: 0;
+ list-style: none;
+ &::before {
+ content: none;
+ }
+}
+
+// Medias
+img.o_we_custom_image {
+ // Images added with the editor are .img-fluid by default but should
+ // still behave like inline content.
+ display: inline-block;
+}
+
+img.shadow {
+ box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
+}
+img.padding-small, .img.padding-small, span.fa.padding-small, iframe.padding-small {
+ padding: 4px;
+}
+img.padding-medium, .img.padding-medium, span.fa.padding-medium, iframe.padding-medium {
+ padding: 8px;
+}
+img.padding-large, .img.padding-large, span.fa.padding-large, iframe.padding-large {
+ padding: 16px;
+}
+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;
+}
+.fa-7x {
+ font-size: 7em;
+}
+.fa-8x {
+ font-size: 8em;
+}
+.fa-9x {
+ font-size: 9em;
+}
+.fa-10x {
+ font-size: 10em;
+}
+.fa.mx-auto {
+ display: block;
+ text-align: center;
+}
+
+div.media_iframe_video {
+ margin: 0 auto;
+ text-align: center;
+ position: relative;
+ overflow: hidden;
+ min-width: 100px;
+
+ iframe {
+ width: 100%;
+ height: 100%;
+ @include o-position-absolute($top: 0);
+ margin: 0 auto;
+ margin-left: -50%;
+ }
+ &.padding-small iframe {
+ padding: 4px;
+ }
+ &.padding-medium iframe {
+ padding: 8px;
+ }
+ &.padding-large iframe {
+ padding: 16px;
+ }
+ &.padding-xl iframe {
+ padding: 32px;
+ }
+
+ .media_iframe_video_size {
+ padding-bottom: 66.5%;
+ position: relative;
+ width: 100%;
+ height: 0;
+ }
+
+ .css_editable_mode_display {
+ @include o-position-absolute(0,0,0,0);
+ width: 100%;
+ height: 100%;
+ display: none;
+ z-index: 2;
+ }
+}
+
+html[data-browser^="msie"] div.media_iframe_video iframe {
+ margin-left: 0;
+}
+
+// Fields
+address {
+ .fa.fa-mobile-phone {
+ margin: 0 3px 0 2px;
+ }
+ .fa.fa-file-text-o {
+ margin-right: 1px;
+ }
+}
+
+span[data-oe-type="monetary"] {
+ white-space: nowrap;
+}
+
+// Menus
+// TODO should not be here but used by web_studio so must stay here for now
+ul.oe_menu_editor {
+ .oe_menu_placeholder {
+ outline: 1px dashed #4183C4;
+ }
+ ul {
+ list-style: none;
+ }
+ li div {
+ cursor: move;
+ }
+}
+
+// Generate all spacings for all sizes
+@mixin o-spacing-all($factor: 1) {
+ // Generate vertical margin/padding classes used by the editor
+ @for $i from 0 through (256 / 8) {
+ @include o-vspacing($i * 8, $factor);
+ }
+ @include o-vspacing(4, $factor);
+
+ // 92px vertical margin is kept for compatibility
+ @include o-vmargins(92, $factor);
+
+ // Some horizontal margin classes defined for convenience
+ // (and compatibility)
+ @include o-hmargins(0, $factor);
+ @include o-hmargins(4, $factor);
+ @include o-hmargins(8, $factor);
+ @include o-hmargins(16, $factor);
+ @include o-hmargins(32, $factor);
+ @include o-hmargins(64, $factor);
+}
+
+// Generate all spacings for one size, scalled by a given factor
+// (0 <= factor <= 1)
+@mixin o-vspacing($name, $factor: 1) {
+ @include o-vmargins($name, $factor);
+ @include o-vpaddings($name, $factor);
+}
+@mixin o-vmargins($name, $factor: 1) {
+ @include o-vmargins-define($name, $factor * $name);
+}
+@mixin o-vpaddings($name, $factor: 1) {
+ @include o-vpaddings-define($name, $factor * $name);
+}
+@mixin o-hspacing($name, $factor: 1) {
+ @include o-hmargins($name, $factor);
+ @include o-hpaddings($name, $factor);
+}
+@mixin o-hmargins($name, $factor: 1) {
+ @include o-hmargins-define($name, $factor * $name);
+}
+@mixin o-hpaddings($name, $factor: 1) {
+ @include o-hpaddings-define($name, $factor * $name);
+}
+
+// Generate all spacings for one size, given the name of the spacing and
+// intended size
+@mixin o-vmargins-define($name, $size: $name) {
+ .mt#{$name} { margin-top: $size * 1px !important; }
+ .mb#{$name} { margin-bottom: $size * 1px !important; }
+}
+@mixin o-vpaddings-define($name, $size: $name) {
+ .pt#{$name} { padding-top: $size * 1px !important; }
+ .pb#{$name} { padding-bottom: $size * 1px !important; }
+}
+@mixin o-hmargins-define($name, $size: $name) {
+ .ml#{$name} { margin-left: $size * 1px !important; }
+ .mr#{$name} { margin-right: $size * 1px !important; }
+}
+@mixin o-hpaddings-define($name, $size: $name) {
+ .pl#{$name} { padding-left: $size * 1px !important; }
+ .pr#{$name} { padding-right: $size * 1px !important; }
+}
+
+// Generate all margins
+@include o-spacing-all;
+
+// Underline
+a.o_underline {
+ text-decoration: underline;
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+// 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%);
+
+ .o_ace_editor_resource_info {
+ color: #ebecee;
+ }
+ }
+
+ #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 {
+ 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;
+ }
+ }
+}
+
+.ace_editor > .ace_gutter {
+ display: block !important; // display even with aria-hidden
+}
+
+.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;
+ }
+ }
+ }
+}
+
+.o_nocontent_help {
+ @include o-nocontent-empty;
+
+ .o_empty_folder_image:before {
+ @extend %o-nocontent-empty-document;
+ }
+}
+
+.o_we_search_prompt {
+ position: relative;
+ min-height: 250px;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ & > h2 {
+ max-width: 500px;
+ text-align: center;
+ }
+
+ &::before {
+ content: "";
+ @include o-position-absolute($top: 0, $right: 50px);
+ width: 100px;
+ height: 150px;
+ opacity: .5;
+ background-image: url('/web_editor/static/src/img/curved_arrow.svg');
+ background-size: 100%;
+ background-repeat: no-repeat;
+ }
+}
+
+@include media-breakpoint-down(sm) {
+ odoo-wysiwyg-container {
+ .panel-heading.note-toolbar {
+ overflow-x: auto;
+ }
+ .btn-group {
+ position: static;
+ }
+ }
+ // modal select media
+ .o_technical_modal.o_web_editor_dialog {
+ // see template 'web_editor.FieldTextHtml.fullscreen'
+ z-index: 2001;
+
+ > .o_select_media_dialog {
+ max-width: inherit !important;
+ z-index: 2001;
+
+ .modal-dialog, .model-content {
+ height: 100%;
+ }
+
+ .modal-body {
+ .nav .nav-item.search {
+ width: 100%;
+
+ .btn-group {
+ display: flex;
+ justify-content: space-around;
+ padding: 5px;
+ }
+ }
+
+ // center pictogram
+ .font-icons-icons {
+ text-align: center;
+ }
+
+ // fix search image
+ .form-control.o_we_search {
+ height: inherit;
+ }
+
+ .form-inline {
+ .btn-group {
+ width: 100%;
+
+ .btn.btn-primary:not(.dropdown-toggle) {
+ width: 90%;
+ }
+ }
+
+ > .input-group.ml-2 {
+ margin-left: 0 !important;
+
+ > .input-group-append {
+ width: 100%;
+
+ > .btn {
+ width: 100%;
+ }
+
+ > .ml-2 {
+ margin-left: 0 !important;
+ }
+ }
+ }
+ }
+
+ // attachment cells
+ .o_we_existing_attachments > .row {
+ flex-direction: column;
+
+ > .o_existing_attachment_cell {
+ flex: initial;
+ max-width: 100%;
+
+ > .o_existing_attachment_remove {
+ opacity: inherit;
+ top: 10px;
+ }
+ }
+ }
+
+ // select media dialog unsplash error
+ #editor-media-image .unsplash_img_container .unsplash_error .mx-auto {
+ width: 100%;
+
+ .form-group {
+ input.w-100 {
+ min-width: 100px;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// BS4 blockquote has no style anymore, except bloquote <footer>
+blockquote {
+ padding: $spacer/2 $spacer;
+ border-left: 5px solid;
+ border-color: gray('300');
+ font-style: italic;
+}
+
+// Bg/text color classes generation
+.o_cc {
+ #{$o-color-extras-nesting-selector} {
+ // Re-force dropdown-item colors inside presets otherwise the presets
+ // 'link' colors take over.
+ .dropdown-menu .dropdown-item { // Need to add +1 priority thanks to
+ // .dropdown-menu to counter a:not(.btn)
+ &, h6 { // Quick fix: sometimes we use h6 in dropdowns
+ color: $dropdown-link-color !important;
+
+ @include hover-focus {
+ color: $dropdown-link-hover-color !important;
+ }
+ }
+ &.disabled,
+ &:disabled {
+ &, h6 { // Quick fix: sometimes we use h6 in dropdowns
+ color: $dropdown-link-disabled-color !important;
+ }
+ }
+ }
+ }
+}
+@for $index from 1 through length($o-color-combinations) {
+ $-bg: color('o-cc#{$index}-bg');
+ $-text: color('o-cc#{$index}-text');
+ $-headings: color('o-cc#{$index}-headings');
+ $-h2: color('o-cc#{$index}-h2');
+ $-h3: color('o-cc#{$index}-h3');
+ $-h4: color('o-cc#{$index}-h4');
+ $-h5: color('o-cc#{$index}-h5');
+ $-h6: color('o-cc#{$index}-h6');
+ $-link: color('o-cc#{$index}-link');
+ $-btn-primary: color('o-cc#{$index}-btn-primary');
+ $-btn-primary-border: color('o-cc#{$index}-btn-primary-border');
+ $-btn-secondary: color('o-cc#{$index}-btn-secondary');
+ $-btn-secondary-border: color('o-cc#{$index}-btn-secondary-border');
+
+ // Those color classes color multiple elements when applied on a snippet.
+ // Those rules are not important so that they can be overridden through
+ // bg and text utility classes. **
+ .o_cc#{$index} {
+ // Background & Text
+ $-bg-color: o-color($-bg);
+ @include o-bg-color($-bg-color, o-color($-text), $important: false, $yiq-min-opacity-threshold: 0);
+
+ #{$o-color-extras-nesting-selector} {
+ // Headings
+ h1, h2, h3, h4, h5, h6 {
+ // 'inherit' comes from the o-bg-color mixin
+ color: o-color($-headings);
+ }
+ h2, h3, h4, h5, h6 {
+ color: o-color($-h2);
+ }
+ h3, h4, h5, h6 {
+ color: o-color($-h3);
+ }
+ h4, h5, h6 {
+ color: o-color($-h4);
+ }
+ h5, h6 {
+ color: o-color($-h5);
+ }
+ h6 {
+ color: o-color($-h6);
+ }
+
+ // Links
+ $-link-color: if($-link, o-color($-link), theme-color('primary'));
+ $-link-hover-color: darken($-link-color, 15%);
+ a:not(.btn), .btn-link {
+ color: auto-contrast($-link-color, $-bg-color, 'o-cc#{$index}-link');
+
+ @include hover {
+ color: auto-contrast($-link-hover-color, $-bg-color, 'o-cc#{$index}-link');
+ }
+ }
+
+ // Buttons
+
+ // Primary
+ $-btn-primary-color: if($-btn-primary, o-color($-btn-primary), theme-color('primary'));
+ $-btn-primary-border-color: if($-btn-primary-border, o-color($-btn-primary-border), $-btn-primary-color);
+ .btn-fill-primary {
+ @include button-variant($-btn-primary-color, $-btn-primary-border-color);
+ }
+ .btn-outline-primary {
+ @include button-outline-variant($-btn-primary-border-color);
+ }
+
+ // Secondary
+ $-btn-secondary-color: if($-btn-secondary, o-color($-btn-secondary), theme-color('secondary'));
+ $-btn-secondary-border-color: if($-btn-secondary-border, o-color($-btn-secondary-border), $-btn-secondary-color);
+ .btn-fill-secondary {
+ @include button-variant($-btn-secondary-color, $-btn-secondary-border-color);
+ }
+ .btn-outline-secondary {
+ @include button-outline-variant($-btn-secondary-border-color);
+ }
+
+ // 'Active' states. Note: this only emulates very common components
+ // used in snippets. This might need to be more complex the day we
+ // can apply color combinations anywhere (page-item, ...).
+ .nav-pills {
+ .nav-link.active,
+ .show > .nav-link {
+ background-color: $-btn-primary-color;
+ color: color-yiq($-btn-primary-color);
+ }
+ }
+ .dropdown-menu .dropdown-item { // Need to add +1 priority thanks to
+ // .dropdown-menu (see .o_cc).
+ &.active,
+ &:active {
+ &, h6 { // Quick fix: sometimes we use h6 in dropdowns
+ @include gradient-bg($-btn-primary-color);
+ color: color-yiq($-btn-primary-color) !important;
+
+ @include hover-focus {
+ color: color-yiq($-btn-primary-color) !important;
+ }
+ }
+ }
+ }
+ a.list-group-item {
+ color: $-btn-primary-color;
+
+ &.active {
+ background-color: $-btn-primary-color;
+ color: color-yiq($-btn-primary-color);
+ border-color: $-btn-primary-color;
+ }
+ }
+ }
+ }
+}
+
+// Extend bootstrap to create background and text utilities for some colors
+// outside of the $theme-colors too (but not btn-, alert-, etc).
+@for $index from 1 through 5 {
+ $-color-name: 'o-color-#{$index}';
+ $-color: color($-color-name);
+ @include bg-variant(".bg-#{$-color-name}", $-color);
+ @include text-emphasis-variant(".text-#{$-color-name}", $-color);
+}
+
+// Base snippet rules
+%o-we-background-layer-parent {
+ &, & > * {
+ // Allow background layers to be placed accordingly and snippet content
+ // to be displayed on top. Note: we cannot just position the layers
+ // with z-index: -1, otherwise it would go under the snippet own
+ // background. Adding a z-index: 0 on the snippet to create its own
+ // stacking context won't solve that either as, in that case, any BS
+ // component inside would be using that stacking context (e.g. a
+ // dropdown inside snippet 1 of the page would go under snippet 2
+ // when opened since the dropdown z-index would be confined into
+ // snippet 1's stacking context.
+ position: relative;
+ }
+}
+%o-we-background-layer {
+ @include o-position-absolute(0, 0, 0, 0);
+ position: absolute !important;
+ display: block;
+ overflow: hidden;
+ background-repeat: no-repeat;
+ pointer-events: none;
+}
+
+section, .oe_img_bg, [data-oe-shape-data] {
+ @extend %o-we-background-layer-parent;
+}
+.o_we_bg_filter {
+ @extend %o-we-background-layer;
+}
diff --git a/addons/web_editor/static/src/scss/web_editor.frontend.scss b/addons/web_editor/static/src/scss/web_editor.frontend.scss
new file mode 100644
index 00000000..232b7b90
--- /dev/null
+++ b/addons/web_editor/static/src/scss/web_editor.frontend.scss
@@ -0,0 +1,74 @@
+@include media-breakpoint-down(sm) {
+ img, .media_iframe_video, span.fa, i.fa {
+ transform: none !important;
+ }
+}
+
+.o_wysiwyg_loader {
+ @extend :disabled;
+ pointer-events: none;
+ min-height: 100px;
+ color: transparent;
+}
+.o_wysiwyg_loading {
+ @include o-position-absolute($top: 50%, $left: 50%);
+ transform: translate(-50%, -50%)
+}
+
+.ui-autocomplete {
+ max-height: 50vh;
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ .ui-menu-item {
+ padding: 0;
+ > .ui-state-active {
+ border: none;
+ font-weight: normal;
+ margin: 0;
+ }
+ }
+}
+
+// Background shapes
+.o_we_shape {
+ @extend %o-we-background-layer;
+
+ @each $module, $shapes in $o-bg-shapes {
+ @each $shape, $style in $shapes {
+ $url-params: '';
+ $colors: map-get($style, 'colors');
+ @each $i in $colors {
+ // %23 is the url-encoded form of '#' which doesn't work as is in urls.
+ $color: str-replace("#{map-get($o-color-palette, "o-color-#{$i}")}", '#', '%23');
+ $url-params: '#{$url-params}&c#{$i}=#{$color}';
+ }
+
+ // eg: o_website_shape_bg_1
+ &.o_#{$module}_#{str-replace($shape, '/', '_')} {
+ // When not customized, this URL, built in SCSS, allows for the
+ // shape to be dynamic and adapted if future color changes.
+ background-image: url("/web_editor/shape/#{$module}/#{$shape}.svg?#{str-slice($url-params, 2)}");
+ background-position: map-get($style, 'position');
+ background-size: map-get($style, 'size');
+ @if map-get($style, 'repeat-y') {
+ background-repeat: repeat-y;
+ }
+ }
+ }
+ }
+}
+@include media-breakpoint-down(sm) {
+ .o_we_shape {
+ display: none;
+ }
+}
+.o_we_flip_x {
+ transform: scaleX(-1);
+}
+.o_we_flip_y {
+ transform: scaleY(-1);
+}
+.o_we_flip_x.o_we_flip_y {
+ transform: scale(-1);
+}
diff --git a/addons/web_editor/static/src/scss/web_editor.variables.scss b/addons/web_editor/static/src/scss/web_editor.variables.scss
new file mode 100644
index 00000000..11cb2be1
--- /dev/null
+++ b/addons/web_editor/static/src/scss/web_editor.variables.scss
@@ -0,0 +1,728 @@
+///
+/// This files regroups the variables and mixins which are specific to the editor.
+///
+
+//------------------------------------------------------------------------------
+// Odoo Editor UI
+//------------------------------------------------------------------------------
+
+$o-we-bg-darkest: #000000 !default;
+$o-we-bg-darker: #141217 !default;
+$o-we-bg-dark: #191922 !default;
+$o-we-bg-light: #2b2b33 !default;
+$o-we-bg-lighter: #3e3e46 !default;
+$o-we-bg-lightest: #595964 !default;
+
+$o-we-fg-darker: #9d9d9d !default;
+$o-we-fg-dark: #C6C6C6 !default;
+$o-we-fg-light: #D9D9D9 !default;
+$o-we-fg-lighter: #FFFFFF !default;
+
+$o-we-color-danger: #e6586c !default;
+$o-we-color-warning: #f0ad4e !default;
+$o-we-color-success: #40ad67 !default;
+$o-we-color-info: #6999a8 !default;
+
+$o-we-bg: $o-we-bg-light !default;
+$o-we-color: $o-we-fg-light !default;
+$o-we-font-size: 13px !default;
+$o-we-font-family: Roboto, 'Montserrat', 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif !default;
+$o-we-accent: #01bad2 !default;
+$o-we-border-width: 1px !default;
+$o-we-border-color: $o-we-bg-light !default;
+
+$o-we-ace-color: #2F3129 !default;
+
+$o-we-handles-offset-to-hide: 10000px !default;
+$o-we-handles-btn-size: 20px !default;
+$o-we-handles-accent-color: $o-we-accent !default;
+$o-we-handles-accent-color-preview: $o-enterprise-color !default;
+$o-we-handle-edge-size: 8px !default;
+$o-we-handle-border-width: 2px !default;
+
+$o-we-dropzone-size: 30px !default; // $grid-gutter-width (todo: allow to use the variable)
+$o-we-dropzone-border-width: 2px !default;
+$o-we-dropzone-border: $o-we-dropzone-border-width dashed $o-brand-odoo !default;
+
+// Translations
+$o-we-content-to-translate-color: rgb(255, 255, 90) !default;
+$o-we-translated-content-color: rgb(120, 215, 110) !default;
+
+$o-we-toolbar-height: 32px !default;
+
+$o-we-item-spacing: 8px !default;
+$o-we-item-border-width: 1px !default;
+$o-we-item-border-color: $o-we-bg-darkest !default;
+$o-we-item-border-radius: 2px !default;
+$o-we-item-clickable-bg: $o-we-bg-lightest!default;
+$o-we-item-clickable-color: $o-we-fg-light!default;
+$o-we-item-clickable-hover-bg: $o-we-bg-dark!default;
+$o-we-item-pressed-bg: $o-we-bg-light !default;
+$o-we-item-pressed-color: $o-we-fg-lighter !default;
+
+$o-we-item-standup-color-light: $o-we-fg-lighter;
+$o-we-item-standup-color-dark: $o-we-bg-darkest;
+$o-we-item-standup-top: inset 0 1px 0;
+$o-we-item-standup-bottom: inset 0 -1px 0;
+
+$o-we-dropdown-spacing: $o-we-item-spacing !default;
+$o-we-dropdown-bg: $o-we-bg-darker !default;
+$o-we-dropdown-border-width: 1px !default;
+$o-we-dropdown-border-color: $o-we-bg-darkest !default;
+$o-we-dropdown-shadow: 0 2px 8px 0 rgba(black, 0.5) !default;
+$o-we-dropdown-item-height: 34px !default;
+$o-we-dropdown-item-spacing: 1px !default;
+$o-we-dropdown-item-bg: $o-we-bg-lightest !default;
+$o-we-dropdown-item-bg-hover: $o-we-bg-light !default;
+$o-we-dropdown-item-color: $o-we-fg-dark !default;
+$o-we-dropdown-item-hover-color: $o-we-fg-light !default;
+$o-we-dropdown-item-active-bg: mix($o-we-dropdown-item-bg, $o-we-dropdown-item-bg-hover) !default;
+$o-we-dropdown-item-active-color: $o-we-fg-lighter !default;
+$o-we-dropdown-caret-spacing: 2px !default;
+
+$o-we-sidebar-bg: $o-we-bg !default;
+$o-we-sidebar-color: $o-we-color !default;
+$o-we-sidebar-font-size: 12px !default;
+$o-we-sidebar-border-width: $o-we-border-width !default;
+$o-we-sidebar-border-color: $o-we-border-color !default;
+$o-we-sidebar-width: $o-we-sidebar-border-width + 290px !default;
+
+$o-we-sidebar-top-height: 46px !default;
+
+$o-we-sidebar-tabs-size-ratio: 1 !default;
+$o-we-sidebar-tabs-bg: $o-we-bg-darker !default;
+$o-we-sidebar-tabs-color: $o-we-sidebar-color !default;
+$o-we-sidebar-tabs-disabled-color: $o-we-fg-darker !default;
+$o-we-sidebar-tabs-active-border-width: 2px !default;
+$o-we-sidebar-tabs-active-border-color: $o-we-accent !default;
+$o-we-sidebar-tabs-active-color: $o-we-fg-lighter !default;
+
+$o-we-sidebar-blocks-content-bg: $o-we-bg-dark !default;
+$o-we-sidebar-blocks-content-spacing: 10px !default;
+$o-we-sidebar-blocks-content-snippet-spacing: 2px !default;
+$o-we-sidebar-blocks-content-snippet-bg: $o-we-bg-lighter !default;
+
+$o-we-sidebar-content-highlight-bar-width: 2px !default;
+$o-we-sidebar-content-highlight-bar-color: $o-we-accent !default;
+
+$o-we-sidebar-content-gutter-item-indent: 5px !default;
+$o-we-sidebar-content-padding-base: 10px !default;
+$o-we-sidebar-content-indent: $o-we-sidebar-content-gutter-item-indent + $o-we-sidebar-content-padding-base !default;
+$o-we-sidebar-content-backdrop-bg: rgba(black, 0.2) !default;
+$o-we-sidebar-content-available-room: $o-we-sidebar-width - $o-we-sidebar-content-padding-base - $o-we-sidebar-content-indent !default;
+
+$o-we-sidebar-content-main-title-height: 32px !default;
+$o-we-sidebar-content-main-title-color: $o-we-fg-lighter !default;
+$o-we-sidebar-content-main-title-font-size: 13px !default;
+
+$o-we-sidebar-content-block-spacing: 10px !default;
+
+$o-we-sidebar-content-fold-block-bg: $o-we-bg-light !default;
+
+$o-we-sidebar-content-field-spacing: $o-we-item-spacing !default;
+$o-we-sidebar-content-field-color: $o-we-fg-darker !default;
+$o-we-sidebar-content-field-control-item-color: $o-we-fg-darker !default;
+$o-we-sidebar-content-field-control-item-size: 1em !default;
+$o-we-sidebar-content-field-control-item-spacing: 0.5em !default;
+$o-we-sidebar-content-field-label-spacing: 6px !default;
+
+$o-we-sidebar-content-field-label-width: $o-we-sidebar-content-available-room * .4 !default;
+$o-we-sidebar-content-field-multi-spacing: $o-we-sidebar-content-field-label-spacing * .5 !default;
+$o-we-sidebar-content-field-height: 22px !default;
+
+$o-we-sidebar-content-field-border-width: $o-we-item-border-width !default;
+$o-we-sidebar-content-field-border-color:$o-we-item-border-color !default;
+$o-we-sidebar-content-field-border-radius: $o-we-item-border-radius !default;
+$o-we-sidebar-content-field-disabled-color: $o-we-sidebar-content-field-control-item-color !default;
+$o-we-sidebar-content-field-clickable-bg: $o-we-item-clickable-bg !default;
+$o-we-sidebar-content-field-clickable-color: $o-we-item-clickable-color !default;
+$o-we-sidebar-content-field-clickable-spacing: $o-we-sidebar-content-field-label-spacing !default;
+$o-we-sidebar-content-field-pressed-bg: $o-we-item-pressed-bg !default;
+$o-we-sidebar-content-field-pressed-color: $o-we-item-pressed-color !default;
+
+$o-we-sidebar-content-field-dropdown-spacing: $o-we-dropdown-spacing !default;
+$o-we-sidebar-content-field-dropdown-bg: $o-we-dropdown-bg !default;
+$o-we-sidebar-content-field-dropdown-border-width: $o-we-dropdown-border-width !default;
+$o-we-sidebar-content-field-dropdown-border-color: $o-we-dropdown-border-color !default;
+$o-we-sidebar-content-field-dropdown-shadow: $o-we-dropdown-shadow !default;
+$o-we-sidebar-content-field-dropdown-item-height: $o-we-dropdown-item-height !default;
+$o-we-sidebar-content-field-dropdown-item-spacing: $o-we-dropdown-item-spacing !default;
+$o-we-sidebar-content-field-dropdown-item-bg: $o-we-dropdown-item-bg !default;
+$o-we-sidebar-content-field-dropdown-item-bg-hover: $o-we-dropdown-item-bg-hover !default;
+$o-we-sidebar-content-field-dropdown-item-color: $o-we-dropdown-item-color !default;
+$o-we-sidebar-content-field-dropdown-item-hover-color: $o-we-dropdown-item-hover-color !default;
+$o-we-sidebar-content-field-dropdown-item-active-bg: $o-we-dropdown-item-active-bg !default;
+$o-we-sidebar-content-field-dropdown-item-active-color: $o-we-dropdown-item-active-color !default;
+
+$o-we-sidebar-content-field-colorpicker-size: 20px !default;
+$o-we-sidebar-content-field-colorpicker-size-large: 26px !default;
+$o-we-sidebar-content-field-colorpicker-shadow: inset 0 0 0 1px rgba(white, 0.5) !default;
+$o-we-sidebar-content-field-colorpicker-dropdown-bg: $o-we-bg-lightest !default;
+$o-we-sidebar-content-field-colorpicker-dropdown-color: $o-we-fg-light !default;
+$o-we-sidebar-content-field-colorpicker-dropdown-active-color: $o-we-fg-lighter !default;
+$o-we-sidebar-content-field-colorpicker-cc-width: 208px !default;
+$o-we-sidebar-content-field-colorpicker-cc-height: 26px !default;
+
+$o-we-sidebar-content-field-input-max-width: 60px !default;
+$o-we-sidebar-content-field-input-bg: $o-we-bg-light !default;
+$o-we-sidebar-content-field-input-font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !default;
+$o-we-sidebar-content-field-input-unit-font-size: 11px !default;
+$o-we-sidebar-content-field-input-border-color: $o-we-accent !default;
+
+$o-we-sidebar-content-field-button-group-button-spacing: $o-we-sidebar-content-field-clickable-spacing;
+
+$o-we-sidebar-content-field-progress-height: 4px !default;
+$o-we-sidebar-content-field-progress-control-height: 10px !default;
+$o-we-sidebar-content-field-progress-color: $o-we-fg-darker !default;
+$o-we-sidebar-content-field-progress-active-color: $o-we-accent !default;
+
+$o-we-sidebar-content-field-toggle-width: 20px !default;
+$o-we-sidebar-content-field-toggle-height: 12px !default;
+$o-we-sidebar-content-field-toggle-bg: $o-we-fg-darker !default;
+$o-we-sidebar-content-field-toggle-active-bg: $o-we-accent !default;
+$o-we-sidebar-content-field-toggle-control-width: 11px !default;
+$o-we-sidebar-content-field-toggle-control-height: $o-we-sidebar-content-field-toggle-height - 2px !default;
+$o-we-sidebar-content-field-toggle-control-bg: $o-we-fg-lighter !default;
+$o-we-sidebar-content-field-toggle-control-shadow: 0 2px 3px 0 $o-we-bg-darkest !default;
+
+//------------------------------------------------------------------------------
+// Preview component Mixins
+//------------------------------------------------------------------------------
+
+@mixin o-we-preview-box($color-text: white) {
+ border-top: 1px solid black;
+ border-bottom: 1px solid white;
+ background-image: linear-gradient(-150deg, $o-we-bg-light, $o-we-bg-dark);
+
+ color: $color-text;
+}
+
+@mixin o-we-preview-content {
+ display: inline-block;
+ max-width: 100%;
+ overflow: hidden;
+ box-shadow: 0 0 15px 2px #000;
+}
+
+//------------------------------------------------------------------------------
+// Mixins to shield UI from themed bootstrap
+//------------------------------------------------------------------------------
+
+@mixin o-w-preserve-base {
+ font-size: $o-we-font-size;
+ font-family: $o-we-font-family;
+ line-height: 1.5;
+ color: #33363e;
+
+ .text-muted {
+ color: #999999 !important;
+ }
+}
+
+@mixin o-w-preserve-headings {
+ h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
+ font-family: $o-we-font-family;
+ line-height: 1.5;
+ color: $o-we-bg-light;
+ font-weight: bold;
+ }
+ h1, .h1 {
+ font-size: 2.4 * $o-we-font-size;
+ }
+ h2, .h2 {
+ font-size: 1.5 * $o-we-font-size;
+ }
+ h3, .h3 {
+ font-size: 1.3 * $o-we-font-size;
+ }
+ h4, .h4 {
+ font-size: 1.2 * $o-we-font-size;
+ }
+ h5, .h5 {
+ font-size: 1.1 * $o-we-font-size;
+ }
+ h6, .h6 {
+ font-size: $o-we-font-size;
+ }
+}
+
+@mixin o-w-preserve-links {
+ a:not(.o_btn_preview) {
+ color: $o-brand-primary;
+
+ &:focus, &:active, &:focus:active {
+ outline: none!important;
+ }
+ }
+
+ .badge {
+ &:hover a, a {
+ color: #fff;
+ }
+ }
+}
+
+@mixin o-w-preserve-forms {
+ :not(.input-group):not(.form-group):not(.input-group-append):not(.input-group-prepend) > .form-control {
+ height: 34px;
+ }
+ .form-control {
+ padding: 6px 12px;
+ font-size: 14px;
+ line-height: 1.5;
+ border: 1px solid #d4d5d7;
+ color: #555;
+ background-color: #fff;
+ border-radius: 0;
+
+ &.is-invalid {
+ border-color: $danger;
+ }
+ }
+ .input-group .form-control {
+ height: auto;
+ }
+ .input-group-text {
+ background-color: #e9ecef;
+ }
+
+ .was-validated {
+ .form-control:invalid {
+ border-color: $danger;
+ }
+ }
+
+ select.form-control {
+ appearance: none;
+ background: url('data:image/svg+xml;base64,PHN2ZyB2ZXJzaW9uPScxLjEnIHhtbG5zPSdodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZycgeG1 sbnM6eGxpbms9J2h0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsnIHdpZHRoPScyNCcgaGVpZ2 h0PScyNCcgdmlld0JveD0nMCAwIDI0IDI0Jz48cGF0aCBpZD0nc3ZnXzEnIGQ9J203LjQwNiw3L jgyOGw0LjU5NCw0LjU5NGw0LjU5NCwtNC41OTRsMC40MDYsMS40MDZsLTUsNC43NjZsLTUsLTQu NzY2bDAuNDA2LC0xLjQwNnonIGZpbGw9JyM4ODgnLz48L3N2Zz4=');
+ background-position: 100% 65%;
+ background-repeat: no-repeat;
+ }
+}
+
+@mixin o-w-preserve-modals {
+ .modal-content {
+ border-radius: 0;
+ background-color: white;
+
+ .modal-header {
+ border-bottom-color: #e9ecef;
+ }
+ .modal-body {
+ background-color: white;
+ }
+ .modal-footer {
+ border-top-color: #e9ecef;
+ text-align: left;
+ }
+ }
+
+ .close {
+ font-size: 1.5 * $o-we-font-size;
+ }
+}
+
+@mixin o-w-preserve-tabs {
+ .nav-tabs {
+ border-bottom: 1px solid #e9ecef;
+
+ > li {
+ > a {
+ line-height: 1.5;
+ color: #4e525b;
+
+ &:hover {
+ border-color: #dee2e6;
+ }
+
+ &.active {
+ &, &:hover, &:focus {
+ color: #3D4047;
+ background-color: white;
+ border-color: #dee2e6 #dee2e6 #FFFFFF;
+ }
+ }
+ }
+ }
+ }
+}
+
+@mixin o-w-preserve-btn {
+ .btn:not(.o_btn_preview) {
+ border-radius: 0;
+ font-weight: normal;
+ text-transform: none;
+ @include button-size(.375rem, .75rem, 0.875rem, 1.5, 0);
+
+ &.btn-primary {
+ @include button-variant($o-brand-primary, $o-brand-primary);
+ color: white;
+ }
+ &.btn-secondary {
+ @include button-variant(white, white);
+ color: $o-brand-primary;
+ }
+ &.btn-link {
+ @include button-variant(white, white);
+ color: $o-brand-primary;
+ }
+ &.btn-success {
+ @include button-variant($o-we-color-success, $o-we-color-success);
+ color: white;
+ }
+ &.btn-info {
+ @include button-variant($o-we-color-info, $o-we-color-info);
+ color: white;
+ }
+ &.btn-warning {
+ @include button-variant($o-we-color-warning, $o-we-color-warning);
+ color: #33363e;
+ }
+ &.btn-danger {
+ @include button-variant($o-we-color-danger, $o-we-color-danger);
+ color: #33363e;
+ }
+ }
+}
+
+@mixin o-w-preserve-cards {
+ .card {
+ padding: 19px;
+ margin-bottom: 20px;
+ background-color: white;
+ border: 1px solid darken(white, 5%);
+ border-radius: 0;
+ box-shadow: none;
+ }
+}
+
+@mixin o-w-preserve-dropdown-menus {
+ .dropdown-menu {
+ background-color: white;
+ }
+ .dropdown-item {
+ color: #212529;
+
+ @include hover-focus {
+ color: darken(#212529, 5%);
+ }
+ &.active,
+ &:active {
+ color: white;
+ @include gradient-bg($o-brand-primary);
+ }
+ }
+}
+
+//------------------------------------------------------------------------------
+// Edited content
+//------------------------------------------------------------------------------
+
+$o-support-13-0-color-system: false !default;
+
+$o-checklist-margin-left: 20px;
+$o-checklist-checkmark-width: 2px;
+$o-checklist-before-size: 13px;
+
+
+// Edition colors
+
+// Note: the "base" palettes contain all possible keys a palette should or
+// must contain, with a default value which should work in use cases where it
+// will be used. Any palette defined by an app will be merged with the base
+// palette once selected to ensure it works.
+
+// Colors
+$o-base-color-palette: (
+ 'o-color-1': transparent,
+ 'o-color-2': transparent,
+ 'o-color-3': transparent,
+ 'o-color-4': transparent,
+ 'o-color-5': transparent,
+) !default;
+$o-color-palettes: (
+ (
+ 'o-color-1': scale-color($o-enterprise-primary-color, $saturation: -50%, $lightness: 20%),
+ 'o-color-2': scale-color($o-enterprise-color, $saturation: -50%),
+ 'o-color-3': #F6F6F6,
+ 'o-color-4': #FFFFFF,
+ 'o-color-5': #383E45,
+ ),
+ (
+ 'o-color-1': #337ab7,
+ 'o-color-2': #e9ecef,
+ 'o-color-3': #F8F9FA,
+ 'o-color-4': #FFFFFF,
+ 'o-color-5': #343a40,
+
+ 'menu': 2,
+ 'footer': 2,
+ 'copyright': 5,
+ ),
+) !default;
+$o-color-palette-number: 1 !default;
+
+// Theme colors
+$o-base-theme-color-palette: () !default;
+$o-theme-color-palettes: (
+ // alpha -> epsilon are old color names kept for compatibility.
+ // They should not be used in the code base anymore and ideally they will
+ // not generate any classes for >= 13.4 databases.
+ (
+ 'alpha': $o-enterprise-primary-color,
+ 'beta': $o-enterprise-color,
+ 'gamma': #5C5B80,
+ 'delta': #5B899E,
+ 'epsilon': #E46F78,
+ ),
+) !default;
+$o-theme-color-palette-number: 1 !default;
+
+// Greyscale transparent colours
+
+// Note: BS values are forced by default in every palette as the values can
+// be used in bootstrap_overridden.scss files through the o-color function.
+// Also, all of the gray colors generates bg- classes in Odoo so black and white
+// are added for the same reason.
+
+$o-base-gray-color-palette: (
+ 'white': #FFFFFF,
+ '100': #F8F9FA,
+ '200': #E9ECEF,
+ '300': #DEE2E6,
+ '400': #CED4DA,
+ '500': #ADB5BD,
+ '600': #6C757D,
+ '700': #495057,
+ '800': #343A40,
+ '900': #212529,
+ 'black': #000000,
+) !default;
+$o-transparent-grays: (
+ 'black-15': rgba(black, 0.15),
+ 'black-25': rgba(black, 0.25),
+ 'black-50': rgba(black, 0.5),
+ 'black-75': rgba(black, 0.75),
+ 'white-25': rgba(white, 0.25),
+ 'white-50': rgba(white, 0.5),
+ 'white-75': rgba(white, 0.75),
+ 'white-85': rgba(white, 0.85),
+) !default;
+$o-gray-color-palettes: () !default;
+$o-gray-color-palette-number: 1 !default;
+
+// Color combinations
+$o-base-color-combination: (
+ 'bg': 'white',
+ 'text': null, // Default to better contrast with the 'bg'
+ 'headings': null, // Default to 'text'
+ 'h2': null, // Default to 'h(x-1)'
+ 'h3': null,
+ 'h4': null,
+ 'h5': null,
+ 'h6': null,
+ 'link': null, // Default to BS 'primary' (= first odoo color)
+ 'btn-primary': null, // Default to BS 'primary' (= first odoo color)
+ 'btn-primary-border': null, // Default to 'btn-primary'
+ 'btn-secondary': null, // Default to BS 'secondary' (= second odoo color)
+ 'btn-secondary-border': null, // Default to 'btn-secondary'
+);
+$o-color-combinations-presets: (
+ (
+ (
+ 'bg': 'o-color-4',
+ ),
+ (
+ 'bg': 'o-color-3',
+ 'headings': 'o-color-1',
+ ),
+ (
+ 'bg': 'o-color-2',
+ 'btn-secondary': 'o-color-3',
+ ),
+ (
+ 'bg': 'o-color-1',
+ 'link': 'o-color-5',
+ 'btn-primary': 'o-color-5',
+ 'btn-secondary': 'o-color-3',
+ ),
+ (
+ 'bg': 'o-color-5',
+ 'headings': 'o-color-4',
+ 'btn-secondary': 'o-color-3',
+ ),
+ ),
+) !default;
+$o-color-combinations-preset-number: 1;
+
+// We allow snippets to be colored and elements like card and columns to be
+// colored as well. We need components targeted by those colored classes to
+// use the deepest coloring element config. We only allow here for this to
+// work for one level of nesting. Note: snippets which can contain other
+// snippets will have problem because of this; this is a limitation of the
+// system until a better solution is found.
+$o-color-extras-nesting-selector: '&, .o_colored_level &';
+
+// Apply colors according to the given identifier. Can either be a preset
+// number, a color name or a css color.
+@mixin o-apply-colors($identifier, $with-extras: true, $background: $body-bg) {
+ $-related-color: o-related-color($identifier, $max-recursions: 10);
+ @if type-of($-related-color) == 'number' {
+ // This is a preset to be applied, just extend it. This should probably
+ // be avoided and use the class in XML if possible.
+ @extend .o_cc#{$-related-color};
+ } @else {
+ @include o-bg-color(o-color($-related-color), $with-extras: $with-extras, $background: $background, $important: false);
+ }
+}
+
+// Function which returns if a color has contrast enough in comparaison to
+// another given color.
+@function has-enough-contrast($color1, $color2) {
+ $r: (max(red($color1), red($color2))) - (min(red($color1), red($color2)));
+ $g: (max(green($color1), green($color2))) - (min(green($color1), green($color2)));
+ $b: (max(blue($color1), blue($color2))) - (min(blue($color1), blue($color2)));
+ $sum-rgb: $r + $g + $b;
+ @return ($sum-rgb >= 500);
+}
+
+// Function which transforms a color to increase its contrast in comparison to
+// another given color.
+@function increase-contrast($color1, $color2) {
+ @if not $color1 or not $color2 {
+ @return null;
+ }
+ $luma-c1: luma($color1);
+ $luma-c2: luma($color2);
+ $lightness-c1: lightness($color1);
+ $lightness-inc: if($luma-c1 < $luma-c2, -1%, 1%);
+ $i: 0;
+ // Max 15% lightness change even if not contrasted enough
+ @while ($lightness-c1 > 0.1% and $lightness-c1 < 99.9% and $i < 15 and not has-enough-contrast($color1, $color2)) {
+ $color1: adjust-color($color1, $lightness: $lightness-inc);
+ $lightness-c1: $lightness-c1 + $lightness-inc;
+ $i: $i + 1;
+ }
+ @return $color1;
+}
+
+// Print a document property the right way (depending on the type of the printed
+// variable).
+@mixin print-variable($key, $value) {
+ @if $value != null {
+ $-type: type-of($value);
+ @if $-type == 'string' {
+ --#{$key}: '#{$value}';
+ } @else if $-type == 'list' {
+ --#{$key}: #{inspect($value)};
+ } @else {
+ --#{$key}: #{$value};
+ }
+ }
+}
+
+// format: (module_name: (shape_filename: ('position': X, 'size': Y, 'colors': (1, [3], ...)), ...))
+$o-bg-shapes: ('web_editor': (
+ 'Airy/01': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Airy/02': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Airy/03': ('position': top, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Airy/04': ('position': center, 'size': 100% 100%, 'colors': (1), 'repeat-y': false),
+ 'Airy/05': ('position': center, 'size': 100% 100%, 'colors': (1), 'repeat-y': false),
+ 'Airy/06': ('position': bottom, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Airy/07': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Airy/08': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Airy/09': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Airy/10': ('position': bottom, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Airy/11': ('position': top, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Airy/12': ('position': top, 'size': 100% auto, 'colors': (1, 3), 'repeat-y': false),
+ 'Airy/13': ('position': bottom, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Airy/14': ('position': bottom, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Blobs/01': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Blobs/02': ('position': bottom, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Blobs/03': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Blobs/04': ('position': center, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Blobs/05': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blobs/06': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blobs/07': ('position': top, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Blobs/08': ('position': right, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blobs/09': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Blobs/10': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Blobs/11': ('position': center, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blobs/12': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Blocks/01': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Blocks/01_001': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Blocks/02': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Blocks/02_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Blocks/03': ('position': bottom, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Blocks/04': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3, 5), 'repeat-y': false),
+ 'Bold/01': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Bold/02': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3), 'repeat-y': false),
+ 'Bold/03': ('position': bottom, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Bold/04': ('position': top, 'size': 100% auto, 'colors': (2, 3), 'repeat-y': false),
+ 'Bold/05': ('position': center, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Bold/05_001': ('position': center, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Bold/06': ('position': center, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Bold/06_001': ('position': center, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Bold/07': ('position': bottom, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Bold/08': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Bold/09': ('position': bottom, 'size': 100% auto, 'colors': (2, 3), 'repeat-y': false),
+ 'Bold/10': ('position': top, 'size': 100% auto, 'colors': (1, 3, 4, 5), 'repeat-y': false),
+ 'Bold/10_001': ('position': top, 'size': 100% auto, 'colors': (1, 4, 5), 'repeat-y': false),
+ 'Bold/11': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3), 'repeat-y': false),
+ 'Bold/11_001': ('position': bottom, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Bold/12': ('position': center, 'size': 100% auto, 'colors': (1, 2, 5), 'repeat-y': false),
+ 'Origins/01': ('position': bottom, 'size': 100% auto, 'colors': (2, 5), 'repeat-y': false),
+ 'Origins/02': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/03': ('position': top, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/04': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/05': ('position': top, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/06': ('position': center, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/07': ('position': center, 'size': 100% 100%, 'colors': (3), 'repeat-y': false),
+ 'Origins/08': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Origins/09': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Origins/10': ('position': bottom, 'size': 100% auto, 'colors': (2, 5), 'repeat-y': false),
+ 'Origins/11': ('position': top, 'size': 100% auto, 'colors': (3, 5), 'repeat-y': false),
+ 'Origins/12': ('position': top, 'size': 100% auto, 'colors': (3, 5), 'repeat-y': false),
+ 'Origins/13': ('position': center, 'size': 100% auto, 'colors': (3, 5), 'repeat-y': false),
+ 'Origins/14': ('position': bottom, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Origins/15': ('position': top, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Rainy/01': ('position': bottom, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Rainy/02': ('position': top, 'size': 100% auto, 'colors': (1, 4, 5), 'repeat-y': false),
+ 'Rainy/03': ('position': top, 'size': 100% auto, 'colors': (2, 4, 5), 'repeat-y': true),
+ 'Rainy/04': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Rainy/05': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Rainy/05_001': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Rainy/06': ('position': bottom, 'size': 100% auto, 'colors': (1, 2, 3), 'repeat-y': false),
+ 'Rainy/07': ('position': top, 'size': 100% auto, 'colors': (1, 2, 3), 'repeat-y': false),
+ 'Rainy/08': ('position': top, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Rainy/09': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/01': ('position': bottom, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Wavy/02': ('position': top, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Wavy/03': ('position': top, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Wavy/04': ('position': bottom, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Wavy/05': ('position': top, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Wavy/06': ('position': top, 'size': 100% auto, 'colors': (1, 3, 4, 5), 'repeat-y': false),
+ 'Wavy/06_001': ('position': top, 'size': 100% auto, 'colors': (1, 3, 5), 'repeat-y': false),
+ 'Wavy/07': ('position': top, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Wavy/08': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Wavy/09': ('position': bottom, 'size': 100% auto, 'colors': (1, 5), 'repeat-y': false),
+ 'Wavy/10': ('position': center, 'size': 100% auto, 'colors': (1, 2), 'repeat-y': false),
+ 'Wavy/11': ('position': bottom, 'size': 100% auto, 'colors': (1, 4), 'repeat-y': false),
+ 'Wavy/12': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/13': ('position': bottom, 'size': 100% auto, 'colors': (4), 'repeat-y': false),
+ 'Wavy/14': ('position': bottom, 'size': 100% auto, 'colors': (1, 3), 'repeat-y': false),
+ 'Wavy/15': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/16': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/17': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Wavy/18': ('position': bottom, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Wavy/19': ('position': top, 'size': 100% auto, 'colors': (5), 'repeat-y': false),
+ 'Wavy/20': ('position': bottom, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Wavy/21': ('position': top, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Wavy/22': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Wavy/23': ('position': top, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+ 'Zigs/01': ('position': bottom, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Zigs/02': ('position': bottom, 'size': 100% auto, 'colors': (2), 'repeat-y': false),
+ 'Zigs/03': ('position': top, 'size': 100% auto, 'colors': (1), 'repeat-y': true),
+ 'Zigs/04': ('position': bottom, 'size': 100% auto, 'colors': (1), 'repeat-y': false),
+ 'Zigs/05': ('position': bottom, 'size': 100% auto, 'colors': (3), 'repeat-y': false),
+));
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 00000000..429da03a
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg.scss
@@ -0,0 +1,522 @@
+$o-we-overlay-zindex: ($zindex-fixed + $zindex-modal-backdrop) / 2 !default;
+$o-we-zindex: $o-we-overlay-zindex + 1 !default;
+
+// Use css variables to control the default style of the editor so that an
+// external assets bundle can influence it without duplicating the css.
+:root {
+ @include print-variable('o-we-toolbar-height', $o-we-toolbar-height);
+}
+
+.o_we_command_protector {
+ font-weight: 400 !important;
+
+ b, strong {
+ font-weight: 700 !important;
+ }
+ * {
+ font-weight: inherit !important;
+ }
+ .btn {
+ text-align: unset !important;
+ }
+}
+
+// EDITOR TOP BAR AND POPOVER
+.note-popover .popover {
+ max-width: 350px;
+ left: 50% !important;
+ transform: translate(-50%, 0);
+
+ .popover-body {
+ white-space: normal;
+ }
+}
+
+#web_editor-top-edit {
+ @include o-position-absolute(0, 0, auto, 0);
+ position: fixed;
+ z-index: $o-we-zindex + 1;
+ height: var(--o-we-toolbar-height);
+ background-color: $o-we-bg;
+
+ .note-popover .popover {
+ top: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ border: none !important;
+ max-width: none;
+ transform: none;
+ }
+ .note-popover .popover .popover-body {
+ height: var(--o-we-toolbar-height);
+ }
+}
+
+.wysiwyg_iframe,
+.note-editor {
+ border: $o-we-border-width solid $o-we-border-color;
+ margin: 0;
+ padding: 0;
+}
+// avoid popover bar over its opened modal
+.note-popover .popover {
+ z-index: $o-we-overlay-zindex;
+}
+.note-popover .popover .popover-body,
+.panel-heading.note-toolbar {
+ padding-bottom: 0;
+ border-bottom: $o-we-border-width solid $o-we-border-color;
+ background-color: $o-we-bg;
+ color: $o-we-color;
+ font-family: $o-we-font-family;
+
+ // Main layout of buttons
+ .btn-group, .btn {
+ width: auto !important;
+ height: 100% !important;
+ margin-top: 0;
+ margin-bottom: 0;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ }
+ .btn-secondary {
+ color: inherit;
+ }
+
+ // Active buttons and opened dropdowns
+ .btn {
+ padding: 0.5em 0.75em !important;
+ border-left: $o-we-item-border-width solid $o-we-item-border-color;
+ border-right: $o-we-item-border-width solid $o-we-item-border-color;
+ background: $o-we-sidebar-content-field-clickable-bg;
+ color: inherit;
+ font-size: $o-we-font-size !important;
+
+ &.active,
+ &:focus, &:active, &:focus:active {
+ @extend %we-active-button;
+ }
+
+ // This element should have been removed but still exists in 13.0 by
+ // mistake. This takes advantage of it to restore the color preview
+ // feature which disappeared and cannot be fixed as in 12.0.
+ // TODO fix the right way in 14.0.
+ > .caret {
+ display: block;
+ @include o-position-absolute(auto, 0, 0, 0);
+ border-bottom: 2px solid transparent;
+ }
+ }
+ .btn-group.show {
+ > .btn {
+ @extend %we-active-button;
+ }
+ &::after {
+ content: '';
+ @include o-position-absolute(100%, $o-we-border-width, auto, $o-we-border-width);
+ height: $o-we-border-width;
+ }
+ }
+ %we-active-button {
+ background: $o-we-sidebar-content-field-pressed-bg;
+ color: $o-we-sidebar-content-field-pressed-color;
+ box-shadow: none !important;
+ outline: none !important;
+ }
+ .dropdown-menu {
+ transform: none !important;
+ margin-top: $o-we-dropdown-spacing;
+ padding: 0;
+ margin-top: $o-we-toolbar-height;
+ border: $o-we-dropdown-border-width solid $o-we-dropdown-border-color;
+ background-color: $o-we-dropdown-bg;
+ box-shadow: $o-we-dropdown-shadow;
+ }
+ .dropdown-menu.show { // To overcome .note-XXX .dropdown-menu rules
+ min-width: 0;
+ }
+ .dropdown-item { // To overcome summernote rules breaking this in iframes
+ display: block;
+ max-width: none;
+ overflow: visible;
+ margin-top: 0;
+ padding: 0 1em;
+ border: none;
+ background: none;
+ background-clip: padding-box;
+ background-color: $o-we-dropdown-item-bg;
+ color: $o-we-dropdown-item-color;
+ line-height: $o-we-dropdown-item-height;
+
+ &:not(.d-none) ~ .dropdown-item {
+ // Use a border-top instead of a margin-top as when the
+ // mouse goes from one select button to another, the
+ // option preview should switch from the first button's
+ // option to the second one without reset to selected
+ // state in between.
+ border-top: $o-we-dropdown-item-spacing solid transparent;
+ }
+
+ &.active {
+ color: $o-we-dropdown-item-active-color;
+ }
+ }
+ li > .dropdown-item {
+ border-top: $o-we-dropdown-item-spacing solid transparent;
+ }
+
+ .note-style {
+ .dropdown-item {
+ > * {
+ display: inline;;
+ }
+ &, > * {
+ line-height: 2;
+ }
+ &[data-value="blockquote"] {
+ padding-top: 0.5em;
+ padding-bottom: 0.5em;
+
+ > * {
+ display: block;
+ }
+ }
+ }
+ }
+
+ // Specific elements
+ .o_image_alt {
+ @include o-text-overflow();
+ max-width: 150px;
+ }
+ .note-color-palette div .note-color-btn {
+ border-color: $o-we-dropdown-bg;
+ }
+ .note-custom-color-palette .note-color-row {
+ height: auto!important;
+ .note-color-btn {
+ float: left;
+ height: 20px;
+ width: 20px;
+ padding: 0;
+ margin: 0;
+ border: 1px solid $o-we-dropdown-bg;
+ }
+ }
+}
+.note-color ul.show {
+ min-width: 216px !important;
+}
+
+// ANIMATIONS
+@keyframes fadeInDownSmall {
+ 0% {
+ opacity: 0;
+ transform: translate(0, -5px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translate(0, 0);
+ }
+}
+
+@keyframes inputHighlighter {
+ from {
+ background: $o-brand-primary;
+ }
+ to {
+ width: 0;
+ background: transparent;
+ }
+}
+
+.o_we_horizontal_collapse {
+ width: 0 !important;
+ padding: 0 !important;
+ border: none !important;
+}
+
+.o_we_transition_ease {
+ transition: all ease 0.35s;
+}
+
+// MODALS
+body .modal {
+
+ // SELECT MEDIA
+ .o_select_media_dialog {
+ max-width: 80%;
+
+ .modal-body {
+ .tab-pane {
+ min-height: 300px;
+ }
+
+ .o_we_images > .o_existing_attachment_cell .o_we_media_dialog_img_wrapper {
+ @extend %o-preview-alpha-background;
+
+ &, > img {
+ width: 100%;
+ }
+ }
+
+ .o_existing_attachment_cell {
+ cursor: pointer;
+ margin: 1px;
+
+ .o_existing_attachment_optimize, .o_existing_attachment_remove {
+ background-color: rgba(white, 0.4);
+ opacity: 0;
+ cursor: pointer;
+ transition: color 0.2s ease;
+ }
+
+ .o_existing_attachment_optimize {
+ @include o-position-absolute($top: 0, $left: 0);
+ border-radius: 0 0 2px 0;
+ }
+
+ .o_existing_attachment_remove {
+ @include o-position-absolute($top: 0, $right: 0);
+ z-index: 1;
+ border-radius: 0 0 0 2px;
+ &:hover {
+ color: $o-we-color-danger;
+ }
+ }
+
+ .o_file_name {
+ @include o-text-overflow;
+ }
+
+ &:hover {
+ .o_existing_attachment_optimize, .o_existing_attachment_remove {
+ opacity: 1;
+ }
+ &.o_we_attachment_highlight, .o_we_attachment_highlight {
+ border-color: $card-border-color;
+ box-shadow: 0px 0px 2px 2px $card-border-color;
+ }
+ }
+ }
+
+ .o_we_attachment_selected {
+ border-color: $o-brand-primary;
+ box-shadow: 0px 0px 2px 2px $o-brand-primary;
+ }
+
+ .o_we_attachment_optimized .badge {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: 2px;
+ }
+
+ .font-icons-icons {
+ > span {
+ text-align: center;
+ font-size: 22px;
+ margin: 5px;
+ width: 50px;
+ height: 50px;
+ padding: 15px;
+ cursor: pointer;
+ }
+ }
+
+ #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;
+ }
+ }
+ }
+
+ #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;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // LINK EDITOR DIALOG COLOR SELECTOR
+ .o_link_dialog {
+ input.link-style:checked + span::after {
+ content: "\f00c";
+ display: inline-block;
+ font-family: FontAwesome;
+ margin-left: 2px;
+ }
+
+ .o_link_dialog_preview {
+ border-left: 1px solid gray('200');
+ }
+ }
+
+ .o_we_image_optimize_dialog {
+ .o_we_title_label {
+ font-size: $o-we-font-size;
+ }
+ .o_we_preview_area {
+ max-height: 400px;
+ overflow: auto;
+ }
+ }
+}
+
+// Highlight selected image/icon
+%o-we-selected-image {
+ outline: 3px solid rgba(150, 150, 220, 0.3);
+}
+
+img.o_we_selected_image {
+ @extend %o-we-selected-image;
+}
+
+.fa.o_we_selected_image::before {
+ @extend %o-we-selected-image;
+}
+// Override default image selection color from portal. It prevents your from
+// seeing the images' quality clearly in the wysiwyg.
+img::selection {
+ background: transparent;
+}
+.o_we_media_author {
+ font-size: 11px;
+ @include o-position-absolute($bottom: 0, $left: 0, $right: 0);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+ background-color: rgba(255, 255, 255, .7);
+}
+
+@include media-breakpoint-down(md) {
+ #web_editor-top-edit {
+ position: initial !important;
+ height: initial !important;
+ top: initial !important;
+ left: initial !important;
+
+ #web_editor-toolbars .popover-body {
+ display: flex;
+ width: 100%;
+ overflow-x: auto;
+
+ .btn-group {
+ position: static;
+ }
+ }
+ }
+}
+
+// User modal in edit mode
+.editor_enable, .note-editable {
+ .modal:not(.o_technical_modal) {
+ top: 40px;
+ right: 0;
+ bottom: 0;
+ right: $o-we-sidebar-width;
+ width: auto;
+ height: auto;
+
+ .modal-dialog {
+ padding: 0.5rem 0; // To use more editor space if necessary
+ }
+ }
+}
+
+.o_we_no_pointer_events {
+ pointer-events: none;
+}
+
+.o_we_crop_widget {
+ background-color: rgba(128, 128, 128, 0.5);
+ @include o-position-absolute(0, 0, 0, 0);
+ z-index: 1024;
+
+ .o_we_cropper_wrapper {
+ position: absolute;
+ }
+
+ .o_we_crop_buttons {
+ margin-top: 0.5rem;
+ display: flex;
+ flex-wrap: wrap;
+
+ input[type=radio] {
+ display: none;
+ }
+
+ .btn-group {
+ border-radius: 0.25rem;
+ margin: 0.1rem;
+ }
+
+ button, label {
+ cursor: pointer !important;
+ padding: 0.2rem 0.3rem;
+ }
+
+ label {
+ display: flex;
+ align-items: center;
+
+ &.active {
+ background-color: $o-we-bg-darkest;
+ }
+ }
+
+ button:not(.btn), label {
+ margin: 0;
+ border: none;
+ border-right: 1px solid $o-we-bg;
+ background-color: $o-we-bg;
+ color: $o-we-color;
+
+ &:first-child {
+ border-top-left-radius: 0.25rem;
+ border-bottom-left-radius: 0.25rem;
+ }
+
+ &:last-child {
+ border-top-right-radius: 0.25rem;
+ border-bottom-right-radius: 0.25rem;
+ border-right: none;
+ }
+ }
+ }
+}
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 00000000..4e00d0bb
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg_iframe.scss
@@ -0,0 +1,27 @@
+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;
+}
+
+.o_wysiwyg_no_transform {
+ transform: none !important;
+}
+
+body.o_in_iframe {
+ background-color: $o-view-background-color;
+
+ &.editor_enable {
+ padding-top: var(--o-we-toolbar-height) !important;
+ }
+
+ .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 00000000..20b32893
--- /dev/null
+++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss
@@ -0,0 +1,1951 @@
+///
+/// This file contains all variables and mixins that are specific to the editor.
+///
+
+// OVERRIDES FOR EDITOR WITH SNIPPETS
+body.editor_enable.editor_has_snippets {
+ padding-right: $o-we-sidebar-width !important;
+
+ #web_editor-top-edit .note-popover .popover {
+ right: $o-we-sidebar-width !important;
+ }
+
+ .modal:not(.o_technical_modal) {
+ top: 0 !important;
+ // set z-index so customize options visible on dialog.
+ z-index: $o-we-overlay-zindex - 1;
+ // just for fake backdrop effect
+ background-color: rgba(66, 66, 66, 0.4);
+ }
+ > .oe_overlay.ui-draggable {
+ .o_handles {
+ display: none;
+ }
+ }
+}
+
+// Mobile fix for mass mailing
+@include media-breakpoint-down(md) {
+ body.editor_enable.editor_has_snippets {
+ #web_editor-top-edit {
+ position: initial !important;
+ height: initial !important;
+ top: initial !important;
+ left: initial !important;
+
+ .note-popover .popover {
+ right: 0 !important;
+ }
+ }
+ }
+}
+
+// Ugly hack to force ugly rules... while waiting for new editor
+#oe_snippets#oe_snippets .o_we_snippet_text_tools {
+ $-text-tools-gap: 3px;
+ $-text-tools-header-height: 35px;
+
+ z-index: $o-we-zindex;
+ flex: 1 0 auto;
+ display: flex;
+ padding: $-text-tools-header-height $o-we-sidebar-content-padding-base ($o-we-sidebar-content-padding-base * 3) $o-we-sidebar-content-indent;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-light, .2);
+ overflow-y: auto;
+
+ .popover {
+ position: static !important;
+ top: 0 !important;
+ left: 0 !important;
+ transform: none !important;
+ border: none !important;
+ }
+ .popover-body {
+ padding: 0 !important;
+ border: none !important;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: $-text-tools-gap;
+
+ > * {
+ margin: 0 0 $o-we-sidebar-content-field-spacing*.5 0;
+ }
+ }
+ .btn {
+ @extend %we-generic-button;
+ border-radius: 0;
+ padding: 0 $o-we-sidebar-content-field-button-group-button-spacing!important;
+ line-height: $o-we-sidebar-content-field-height + ($o-we-sidebar-content-field-border-width*2)!important;
+ }
+ // Achieve a "button-group effect" for siblings buttons.
+ .popover-body > .btn-group {
+ > .btn {
+ border-radius: 0;
+ }
+ > .btn:first-of-type, > div:first-of-type .btn {
+ @include border-left-radius($o-we-sidebar-content-field-border-radius);
+ }
+ > .btn:last-of-type, > div:last-of-type .btn {
+ @include border-right-radius($o-we-sidebar-content-field-border-radius);
+ }
+ }
+ .btn-group {
+
+ &.note-color {
+ order: -3;
+
+ .note-back-color-preview {
+ margin-left: $-text-tools-gap;
+ }
+ .btn::after {
+ display: none;
+ }
+ }
+ &.note-style {
+ order: -2;
+
+ &, > div {
+ flex-grow: 1;
+ }
+ }
+ &.note-fontsize {
+ order: -1;
+ }
+ &.note-font [data-name="clear"] {
+ @include o-position-absolute(($-text-tools-header-height*-1) + 5px, 0);
+ background: 0;
+ border: 0;
+ @extend %we-generic-link;
+ }
+ &.note-para {
+ flex-grow: 1;
+ gap: $-text-tools-gap;
+ justify-content: space-between;
+
+ > * {
+ flex: 1 1 33%;
+ }
+ }
+ > .d-none + * {
+ margin-left: 0 !important;
+ }
+ }
+ .note-color {
+ .btn-group {
+ position: static;
+ }
+ .dropdown-menu { // Drop up
+ margin-top: $o-we-dropdown-spacing;
+ }
+ }
+ .note-popover ~ .note-popover,
+ .note-handle ~ .note-handle,
+ .note-dialog ~ .note-dialog {
+ // Prevent flickering of summernote when switching text tools...
+ display: none;
+ }
+}
+
+.oe_snippet {
+ // No root because can be drag and drop (and the helper is in the body)
+ position: relative;
+ z-index: $o-we-zindex;
+ width: 77px;
+ background-color: $o-we-sidebar-blocks-content-snippet-bg;
+
+ &.ui-draggable-dragging {
+ transform: rotate(-3deg) scale(1.2);
+ box-shadow: 0 5px 25px -10px black;
+ transition: transform 0.3s, box-shadow 0.3s;
+ }
+
+ > .oe_snippet_body {
+ display: none !important;
+ }
+
+ .oe_snippet_thumbnail {
+ width: 100%;
+
+ .oe_snippet_thumbnail_img {
+ width: 100%;
+ padding-top: 75%;
+ background-repeat: no-repeat;
+ background-size: contain;
+ background-position: top center;
+ overflow: hidden;
+ }
+ }
+
+ .oe_snippet_thumbnail_title {
+ display: none;
+ }
+
+ &:not(:hover):not(.o_disabled):not(.o_snippet_install) {
+ background-color: rgba($o-we-sidebar-blocks-content-snippet-bg, .9);
+
+ .oe_snippet_thumbnail {
+ filter: saturate(.7);
+ opacity: .9;
+ }
+ }
+}
+
+@mixin we-svg-icon(
+ $graphic: $o-we-sidebar-content-field-color,
+ $subdle: $o-we-sidebar-content-field-color,
+ $subdle-opacity: 0.5) {
+ svg {
+ .o_graphic {
+ fill: $graphic;
+ }
+ .o_subdle {
+ fill: rgba($subdle, $subdle-opacity);
+ }
+ }
+}
+
+%we-generic-clickable {
+ outline: none;
+ text-decoration: none;
+ line-height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
+ cursor: pointer;
+
+ &[disabled] {
+ opacity: .5;
+ }
+
+ &:not([disabled]) {
+ &.active:not(.o_we_no_toggle):not(.o_we_checkbox_wrapper), &:hover {
+ color: $o-we-sidebar-content-field-pressed-color;
+ }
+
+ $-hover-colors: (
+ 'success': $o-we-color-success,
+ 'info': $o-we-color-info,
+ 'warning': $o-we-color-warning,
+ 'danger': $o-we-color-danger,
+ );
+
+ @each $name, $color in $-hover-colors {
+ &.o_we_text_#{$name} {
+ color: $color;
+ }
+
+ &.o_we_hover_#{$name}:hover {
+ color: $color;
+ }
+ }
+ }
+}
+
+%we-generic-link {
+ color: $o-we-sidebar-content-field-color;
+ @include we-svg-icon();
+ @extend %we-generic-clickable;
+
+ &:not([disabled]) {
+ &.active, &:hover {
+ @include we-svg-icon($o-we-sidebar-content-field-pressed-color, $subdle-opacity: .75);
+ }
+ }
+}
+
+%we-generic-button {
+ @extend %we-generic-clickable;
+ @include o-text-overflow(block);
+ @include we-svg-icon($o-we-sidebar-content-field-clickable-color, $o-we-sidebar-content-field-clickable-color);
+
+ padding: 0 $o-we-sidebar-content-field-button-group-button-spacing;
+ border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ border-radius: $o-we-sidebar-content-field-border-radius;
+ background-color: $o-we-sidebar-content-field-clickable-bg;
+ color: $o-we-sidebar-content-field-clickable-color;
+
+ &:not([disabled]):hover, &.active:not(.o_we_no_toggle) {
+ @include we-svg-icon($o-we-sidebar-content-field-pressed-color, $subdle-opacity: .75);
+ }
+
+ &.active:not(.o_we_no_toggle) {
+ background-color: $o-we-sidebar-content-field-pressed-bg;
+ }
+}
+
+%we-generic-tab-button {
+ @extend %we-generic-link;
+ @include o-text-overflow(inline-flex);
+ flex: 1 1 auto;
+ justify-content: center;
+ border: none;
+ background-color: transparent;
+ color: inherit;
+ font-weight: normal;
+
+ > span {
+ display: inline-block;
+ $-r: $o-we-sidebar-tabs-size-ratio;
+ padding: (0.6em * $-r) (0.4em * $-r) (0.5em * $-r);
+ }
+ &.active > span {
+ color: $o-we-sidebar-content-field-colorpicker-dropdown-active-color;
+ box-shadow: inset 0 ($o-we-sidebar-tabs-active-border-width * -1) 0 $o-we-sidebar-tabs-active-border-color;
+ }
+}
+
+// SNIPPET PANEL
+#oe_snippets {
+ @include o-w-preserve-btn;
+
+ @include o-position-absolute(var(--o-we-toolbar-height), 0, 0, auto);
+ position: fixed;
+ z-index: $o-we-zindex;
+ display: flex;
+ flex-flow: column nowrap;
+ width: $o-we-sidebar-width;
+
+ border-left: $o-we-sidebar-border-width solid $o-we-sidebar-border-color;
+ background-color: $o-we-sidebar-bg;
+ color: $o-we-sidebar-color;
+ font-family: $o-we-font-family;
+ font-size: $o-we-sidebar-font-size;
+ font-weight: 400;
+
+ transition: transform 400ms ease 0s;
+ transform: translateX(100%);
+
+ &.o_loaded {
+ transform: none;
+ }
+
+ *::selection {
+ background: lighten($o-we-accent, 9);
+ color: $o-we-bg-darkest;
+ }
+
+ #snippets_menu {
+ flex: 0 0 auto;
+ display: flex;
+ background-color: $o-we-sidebar-tabs-bg;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-light, .2);
+ color: $o-we-sidebar-tabs-color;
+
+ > button {
+ @extend %we-generic-tab-button;
+ }
+ }
+
+ // Snippet filter input
+ .o_snippet_search_filter {
+ position: relative;
+ box-shadow: $o-we-item-standup-bottom $o-we-item-standup-color-dark, 0 10px 10px rgba($o-we-item-standup-color-dark, .2);
+ z-index: 2;
+
+ &, .o_snippet_search_filter_input {
+ width: 100%;
+ }
+
+ .o_snippet_search_filter_input {
+ background-color: $o-we-sidebar-content-field-input-bg;
+ padding: $o-we-sidebar-blocks-content-spacing 2em $o-we-sidebar-blocks-content-spacing $o-we-sidebar-blocks-content-spacing;
+ border: 0;
+ border-bottom: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ color: $o-we-fg-lighter;
+
+ &::placeholder {
+ font-style: italic;
+ color: $o-we-sidebar-content-field-control-item-color;
+ }
+
+ &:focus {
+ background-color: $o-we-bg-lighter;
+ outline: none;
+ }
+ }
+
+ .o_snippet_search_filter_reset {
+ @include o-position-absolute($o-we-sidebar-blocks-content-spacing, $o-we-sidebar-blocks-content-spacing, $o-we-sidebar-blocks-content-spacing);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0 $o-we-sidebar-content-field-clickable-spacing;
+ @include o-hover-text-color($o-we-sidebar-content-field-control-item-color, $o-we-fg-lighter);
+ cursor: pointer;
+ }
+ }
+
+ > #o_scroll, > .o_we_customize_panel {
+ min-height: 0;
+ overflow: auto;
+ }
+
+ > #o_scroll {
+ background-color: $o-we-sidebar-blocks-content-bg;
+ padding: 0 $o-we-sidebar-blocks-content-spacing;
+ z-index: 1;
+
+ .o_panel, .o_panel_header {
+ padding: $o-we-sidebar-blocks-content-spacing 0;
+ }
+
+ .o_panel_body {
+ display: flex; // Needed for too long snippet names
+ flex-wrap: wrap;
+ margin-left: -$o-we-sidebar-blocks-content-snippet-spacing;
+
+ > .oe_snippet {
+ flex: 0 0 auto;
+ width: percentage(1 / 3);
+ background-clip: padding-box;
+ border-left: $o-we-sidebar-blocks-content-snippet-spacing solid transparent;
+ margin-bottom: $o-we-sidebar-blocks-content-snippet-spacing;
+ user-select: none;
+
+ cursor: pointer;
+ cursor: copy;
+ cursor: grab;
+
+ .oe_snippet_thumbnail_title {
+ display: block;
+ padding: $o-we-sidebar-blocks-content-spacing / 2;
+ text-align: center;
+ }
+
+ &.o_disabled .o_snippet_undroppable {
+ @include o-position-absolute(8px, 6px, auto, auto);
+ }
+
+ &.o_snippet_install {
+ .btn.o_install_btn {
+ @include o-position-absolute($top: 10px);
+ }
+
+ &:not(:hover) .btn.o_install_btn {
+ display: none;
+ }
+ }
+
+ &.o_disabled, &.o_snippet_install {
+ background-color: rgba($o-we-sidebar-blocks-content-snippet-bg, .2);
+
+ .oe_snippet_thumbnail_img {
+ opacity: .4;
+ filter: saturate(0) blur(1px);
+ }
+ }
+ }
+ }
+ #snippet_custom .oe_snippet {
+ width: 100%;
+
+ &, .oe_snippet_thumbnail, .o_delete_btn {
+ display: flex;
+ }
+ .oe_snippet_thumbnail, .o_delete_btn {
+ align-items: center;
+ }
+ .oe_snippet_thumbnail {
+ min-width: 0; // Ensure text-overflow on flex children
+ }
+ .oe_snippet_thumbnail_title {
+ @include o-text-overflow(block);
+ }
+ .oe_snippet_thumbnail_img {
+ flex-shrink: 0;
+ width: 40px;
+ height: 32px;
+ padding: 0;
+ }
+ .o_delete_btn {
+ @extend %we-generic-link;
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+ }
+ }
+
+ > .o_we_customize_panel {
+ position: relative;
+
+ @mixin we-icon-button($icon, $color: $o-we-sidebar-content-field-control-item-color, $align: right) {
+ @extend %we-icon-button;
+ padding-#{$align}: 2 * $o-we-sidebar-content-field-control-item-spacing + $o-we-sidebar-content-field-control-item-size;
+
+ &::after {
+ content: $icon;
+ color: $color;
+
+ @if $align == left {
+ right: auto;
+ left: $o-we-sidebar-content-field-control-item-spacing;
+ }
+ }
+ }
+
+ @mixin large-component() {
+ flex: 1 1 auto;
+ width: $o-we-sidebar-content-available-room * .6;
+ }
+
+ we-button, we-toggler {
+ @extend %we-generic-button;
+ }
+
+ we-button.o_we_link {
+ @extend %we-generic-link;
+ margin-top: 0;
+ border: 0;
+ padding: 0;
+ background: 0;
+ }
+
+ we-toggler {
+ @include we-icon-button('\f0d7');
+ text-align: left;
+
+ > img, > svg {
+ max-width: 100%;
+ }
+
+ + * {
+ display: none !important;
+ border: $o-we-sidebar-content-field-dropdown-border-width solid $o-we-sidebar-content-field-dropdown-border-color;
+ background-color: $o-we-sidebar-content-field-dropdown-bg;
+ box-shadow: $o-we-sidebar-content-field-dropdown-shadow;
+ }
+ &.active {
+ @include we-icon-button('\f0d8');
+ + * {
+ display: block !important;
+ }
+ }
+ }
+ %we-icon-button {
+ position: relative;
+
+ &::after {
+ @include o-position-absolute(50%, $o-we-sidebar-content-field-control-item-spacing);
+ transform: translateY(-50%);
+ width: $o-we-sidebar-content-field-control-item-size;
+ text-align: center;
+ font-family: FontAwesome;
+ }
+ }
+
+ we-title {
+ display: block;
+ text-transform: capitalize;
+ }
+
+ we-customizeblock-options {
+ position: relative;
+ display: block;
+ padding: 0 0 ($o-we-sidebar-content-block-spacing * 1.5) 0;
+ background-color: $o-we-bg-lighter;
+ box-shadow: $o-we-item-standup-bottom rgba($o-we-item-standup-color-dark, 0.8);
+
+ > we-title {
+ display: flex;
+ align-items: center;
+ padding: $o-we-sidebar-content-block-spacing * .3 $o-we-sidebar-content-padding-base 0 $o-we-sidebar-content-indent;
+ background-color: $o-we-bg-light;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-light, .2), $o-we-item-standup-bottom rgba($o-we-item-standup-color-dark, .5);
+ font-size: $o-we-sidebar-content-main-title-font-size;
+
+ > span {
+ @include o-text-overflow();
+ flex: 1 1 auto; // Make it full-width so that it is easier to click on
+ cursor: pointer;
+
+ color: $o-we-sidebar-content-main-title-color !important;
+ line-height: $o-we-sidebar-content-main-title-height;
+ }
+
+ > we-top-button-group {
+ flex: 0 0 auto;
+ display: flex;
+ margin-left: auto;
+ font-size: .9em;
+
+ .oe_snippet_remove {
+ font-size: 1.2em;
+ }
+ we-customizeblock-option {
+ display: flex;
+ padding: 0;
+ }
+ we-button {
+ margin-top: 0 !important;
+ margin-left: $o-we-sidebar-content-field-multi-spacing;
+ padding: 0 $o-we-sidebar-content-field-multi-spacing;
+
+ &.fa {
+ margin-left: $o-we-sidebar-content-field-label-spacing;
+ }
+ }
+ }
+ }
+ }
+
+ we-customizeblock-option {
+ position: relative;
+ display: block;
+ padding: 0 $o-we-sidebar-content-padding-base 0 $o-we-sidebar-content-indent;
+
+ .dropdown-menu {
+ // FIXME temporary fix for m2o option for example
+ position: static !important;
+ }
+
+ > we-alert {
+ background-color: $o-we-color-info;
+ display: block;
+ padding: $o-we-sidebar-content-field-label-spacing;
+
+ we-title {
+ margin-bottom: $o-we-sidebar-content-field-label-spacing;
+ text-transform: uppercase;
+ font-weight: bold;
+ }
+ }
+
+ > we-title {
+ margin-bottom: $o-we-sidebar-content-field-spacing * -0.5;
+ font-size: $o-we-sidebar-font-size + 1;
+ color: $o-we-fg-lighter;
+ font-weight: 500;
+
+ &:not(:first-child) {
+ margin-top: $o-we-sidebar-content-field-spacing * 2;
+ }
+ }
+ }
+
+ .o_we_fold_icon {
+ @include o-position-absolute(0, 100%, 0, -$o-we-sidebar-content-indent);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: $o-we-sidebar-content-indent;
+
+ @extend %we-generic-link;
+ }
+
+ //----------------------------------------------------------------------
+ // User Value Widgets
+ //----------------------------------------------------------------------
+
+ .o_we_user_value_widget {
+ @extend %o-we-inline;
+ margin-top: $o-we-sidebar-content-field-spacing;
+
+ > div {
+ display: flex;
+ align-items: center;
+ min-height: $o-we-sidebar-content-field-height;
+ }
+ }
+
+ // Buttons
+ we-button.o_we_user_value_widget {
+ > div {
+ // Needed otherwise cannot work because of flex display
+ @include o-text-overflow(block);
+ min-height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
+ line-height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
+
+ > img {
+ margin-bottom: 1px; // Not sure why but not really centered otherwise
+ }
+ > svg {
+ margin-bottom: 2px; // Not sure why but not really centered otherwise
+ }
+ }
+ &.fa > div {
+ display: none;
+ }
+ }
+
+ // Checkboxes
+ we-button.o_we_checkbox_wrapper.o_we_user_value_widget {
+ min-width: $o-we-sidebar-content-field-toggle-width;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: default;
+
+ > we-title {
+ cursor: pointer;
+ }
+ > div {
+ display: flex;
+ min-height: $o-we-sidebar-content-field-height;
+ line-height: $o-we-sidebar-content-field-height;
+ }
+ we-checkbox {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ width: $o-we-sidebar-content-field-toggle-width;
+ height: $o-we-sidebar-content-field-toggle-height;
+ background-color: $o-we-sidebar-content-field-toggle-bg;
+ border-radius: 10rem;
+ cursor: pointer;
+
+ &::after {
+ content: "";
+ display: block;
+ width: $o-we-sidebar-content-field-toggle-control-width;
+ height: $o-we-sidebar-content-field-toggle-control-height;
+ border-radius: 10rem;
+ background-color: $o-we-sidebar-content-field-toggle-control-bg;
+ box-shadow: $o-we-sidebar-content-field-toggle-control-shadow;
+ }
+ }
+ &.active we-checkbox {
+ background-color: $o-we-sidebar-content-field-toggle-active-bg;
+ justify-content: flex-end;
+ }
+ &.active, &:hover {
+ color: $o-we-sidebar-content-field-clickable-color;
+ }
+ }
+
+ // Selection (select and button groups)
+ we-selection-items {
+ .o_we_user_value_widget {
+ margin-top: 0;
+ }
+ }
+
+ // Selects
+ we-select.o_we_user_value_widget {
+ position: relative;
+
+ &:not(.o_we_icon_select) we-toggler {
+ @include large-component();
+ }
+ &.o_we_widget_opened .o_we_dropdown_caret {
+ position: relative;
+ display: block;
+ align-self: flex-end;
+
+ &::before, &::after {
+ content: '';
+ $-toggler-caret-size: 2 * $o-we-sidebar-content-field-control-item-spacing + $o-we-sidebar-content-field-control-item-size;
+ @include o-position-absolute(100%, $-toggler-caret-size);
+ z-index: $zindex-dropdown + 1;
+ transform: translateX(50%);
+ margin-top: $o-we-dropdown-caret-spacing;
+ border-bottom: ($o-we-item-spacing + $o-we-sidebar-content-field-dropdown-border-width - $o-we-dropdown-caret-spacing) solid $o-we-dropdown-border-color;
+ border-left: $o-we-item-spacing solid transparent;
+ border-right: $o-we-item-spacing solid transparent;
+ }
+ &::after {
+ border-bottom-color: $o-we-sidebar-content-field-dropdown-item-bg;
+ border-left-width: ($o-we-item-spacing - 1px);
+ border-right-width: ($o-we-item-spacing - 1px);
+ }
+ }
+ &:not(.o_we_so_color_palette) + we-button:not(:hover) {
+ background: none;
+ }
+
+ we-selection-items {
+ @include o-position-absolute(100%, 0, auto, 0);
+ z-index: $zindex-dropdown;
+ margin-top: $o-we-sidebar-content-field-dropdown-spacing !important;
+
+ &:not(.o_we_has_pager) {
+ max-height: 600px;
+ overflow-y: auto;
+ }
+
+ > we-title {
+ line-height: $o-we-sidebar-content-field-dropdown-item-height;
+ }
+
+ we-button {
+ @include we-icon-button('', $align: left); // Always a padding on the left
+ border: none;
+ background: none;
+ background-clip: padding-box;
+ background-color: $o-we-sidebar-content-field-dropdown-item-bg;
+ color: $o-we-sidebar-content-field-dropdown-item-color;
+
+ > we-title {
+ flex-grow: 1;
+ }
+
+ > div, > we-title {
+ line-height: $o-we-sidebar-content-field-dropdown-item-height;
+
+ img, svg {
+ max-width: 100%;
+ }
+ }
+
+ &:not(.d-none) ~ we-button {
+ // Use a border-top instead of a margin-top as when the
+ // mouse goes from one select button to another, the
+ // option preview should switch from the first button's
+ // option to the second one without reset to selected
+ // state in between.
+ border-top: $o-we-sidebar-content-field-dropdown-item-spacing solid transparent;
+ }
+
+ &:hover {
+ background-color: $o-we-sidebar-content-field-dropdown-item-bg-hover;
+ color: $o-we-sidebar-content-field-dropdown-item-hover-color;
+ }
+ &.active {
+ @include we-icon-button('\f00c', $align: left);
+ background-color: $o-we-sidebar-content-field-dropdown-item-active-bg;
+ color: $o-we-sidebar-content-field-dropdown-item-active-color;
+
+ &:after {
+ color: $o-we-accent;
+ }
+ }
+ }
+ }
+ .o_we_pager_header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: $o-we-sidebar-content-field-dropdown-item-bg;
+ margin-bottom: 1px;
+
+ & > b {
+ padding: $o-we-sidebar-content-field-label-spacing;
+ color: $o-we-fg-lighter;
+ }
+ }
+ .o_we_pager_controls {
+ display: flex;
+ align-items: center;
+
+ > span {
+ margin: 0 $o-we-sidebar-content-field-label-spacing;
+ }
+ }
+ .o_we_pager_next, .o_we_pager_prev {
+ margin: 0.3em;
+ padding: $o-we-sidebar-content-field-label-spacing;
+ cursor: pointer;
+ border: $o-we-item-border-width solid currentColor;
+ border-radius: $o-we-item-border-radius;
+ }
+ we-select-page {
+ display: none;
+ width: 100%;
+ // Cut the last visible option in the list to understand that we can scroll.
+ max-height: 75px * 7.5;
+ overflow-y: auto;
+
+ &.active {
+ display: block;
+ }
+ }
+ }
+
+ // Button groups
+ we-button-group.o_we_user_value_widget {
+ we-selection-items {
+ display: flex;
+ max-width: 100%;
+
+ we-button {
+ padding: 0 $o-we-sidebar-content-field-button-group-button-spacing;
+ border-radius: 0;
+
+ + we-button {
+ border-left: none;
+ }
+ &:first-child {
+ @include border-left-radius($o-we-sidebar-content-field-border-radius);
+ }
+ &:last-child {
+ @include border-right-radius($o-we-sidebar-content-field-border-radius);
+ }
+ }
+ }
+ }
+ // Only when main option (not in a we-row or something like that...)
+ we-customizeblock-option > we-button-group.o_we_user_value_widget we-selection-items {
+ @include large-component();
+
+ we-button {
+ display: flex;
+ justify-content: center;
+ flex: 0 1 percentage(1/4);
+ padding: ($o-we-sidebar-content-field-button-group-button-spacing / 4) ($o-we-sidebar-content-field-button-group-button-spacing / 3);
+ text-align: center;
+ }
+ }
+
+ // Inputs
+ we-input.o_we_user_value_widget {
+
+ > div {
+ flex: 0 1 auto;
+ width: $o-we-sidebar-content-field-input-max-width;
+ border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ border-radius: $o-we-sidebar-content-field-border-radius;
+ background-color: $o-we-sidebar-content-field-input-bg;
+
+ &:focus-within {
+ border-color: $o-we-sidebar-content-field-input-border-color;
+ }
+
+ > we-button { // for input-group
+ border: none;
+ }
+ }
+
+ &.o_we_large_input > div {
+ flex: 1 1 auto;
+ }
+
+ input {
+ box-sizing: content-box;
+ flex: 1 1 auto;
+ width: 0;
+ min-width: 2ch;
+ height: $o-we-sidebar-content-field-height - 2 * $o-we-sidebar-content-field-border-width;
+ padding: 0 $o-we-sidebar-content-field-clickable-spacing;
+ border: none;
+ border-radius: 0;
+ background-color: transparent;
+ color: inherit;
+ font-family: $o-we-sidebar-content-field-input-font-family;
+
+ &::placeholder {
+ color: $o-we-sidebar-content-field-control-item-color;
+ }
+ &:focus {
+ outline: none;
+ }
+ }
+ span {
+ flex: 0 0 auto;
+ padding-right: $o-we-sidebar-content-field-label-spacing;
+ font-size: $o-we-sidebar-content-field-input-unit-font-size;
+ color: $o-we-sidebar-content-field-control-item-color;
+ }
+ }
+
+ // Color Pickers
+ .o_we_so_color_palette.o_we_user_value_widget {
+
+ .o_we_color_preview {
+ @extend %o-preview-alpha-background;
+ flex: 0 0 auto;
+ display: block;
+ width: $o-we-sidebar-content-field-colorpicker-size;
+ height: $o-we-sidebar-content-field-colorpicker-size;
+ border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ border-radius: 10rem;
+
+ &::after {
+ box-shadow: $o-we-sidebar-content-field-colorpicker-shadow;
+ }
+ }
+
+ &.o_we_widget_opened {
+
+ .o_we_color_preview {
+ border: 2px solid $o-we-accent;
+ }
+ .o_we_dropdown_caret {
+ &::before, &::after {
+ right: $o-we-sidebar-content-field-colorpicker-size / 2;
+ }
+ &::after {
+ border-bottom-width: ($o-we-item-spacing + $o-we-sidebar-content-field-dropdown-border-width - $o-we-dropdown-caret-spacing) + 1px; // 1px = colorpicker inset box-shadow...
+ }
+ }
+ }
+
+ we-toggler {
+ display: none;
+ }
+ }
+
+ // Matrix (e.g. Chart Snippet)
+ we-matrix {
+ overflow-y: auto;
+
+ table {
+ table-layout: fixed;
+ width: 100%;
+
+ td, th {
+ text-align: center;
+ we-button {
+ display: inline-block;
+ color: inherit;
+ height: 100%;
+
+ &.o_we_matrix_remove_col, &.o_we_matrix_remove_row {
+ display: none;
+ }
+ }
+ input {
+ border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color;
+ background-color: $o-we-sidebar-content-field-input-bg;
+ color: inherit;
+ font-size: 12px;
+ width: 100%;
+ }
+ &:last-child {
+ width: 28px;
+ }
+ }
+ &.o_we_matrix_five_col {
+ width: auto;
+ td, th {
+ input {
+ width: 43px;
+ }
+ }
+ }
+ }
+ }
+
+ // Progress bar widget
+ we-range.o_we_user_value_widget {
+
+ input[type="range"] {
+ @include large-component();
+ height: $o-we-sidebar-content-field-height;
+ padding: 0 $o-we-item-border-width 0 0;
+ background-color: transparent;
+ appearance: none;
+
+ &:focus {
+ outline: none;
+
+ &::-webkit-slider-thumb { box-shadow: none; }
+ &::-moz-range-thumb { box-shadow: none; }
+ &::-ms-thumb { box-shadow: none; }
+ }
+ &::-moz-focus-outer {
+ border: 0;
+ }
+ &::-webkit-slider-thumb {
+ width: $o-we-sidebar-content-field-progress-control-height;
+ height: $o-we-sidebar-content-field-progress-control-height;
+ margin-top: ($o-we-sidebar-content-field-progress-height - $o-we-sidebar-content-field-progress-control-height) / 2;
+ border: none;
+ border-radius: 10rem;
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ box-shadow: none;
+ appearance: none;
+
+ &:active {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ }
+ &::-webkit-slider-runnable-track {
+ width: 100%;
+ height: $o-we-sidebar-content-field-progress-height;
+ cursor: pointer;
+ // Unfortunately, Chrome does not support customizing the lower part of the track
+ background-color: $o-we-sidebar-content-field-progress-color;
+ border-color: transparent;
+ border-radius: 10rem;
+ box-shadow: none;
+
+ position: relative;
+ // z-index: 1000;
+ }
+ &::-moz-range-thumb {
+ width: $o-we-sidebar-content-field-progress-control-height;
+ height: $o-we-sidebar-content-field-progress-control-height;
+ border: none;
+ border-radius: 10rem;
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ box-shadow: none;
+ appearance: none;
+
+ &:active {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ }
+ &::-moz-range-track {
+ width: 100%;
+ height: $o-we-sidebar-content-field-progress-height;
+ cursor: pointer;
+ background-color: $o-we-sidebar-content-field-progress-color;
+ border-color: transparent;
+ border-radius: 10rem;
+ box-shadow: none;
+ }
+ &::-moz-range-progress {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ height: $o-we-sidebar-content-field-progress-height;
+ border-color: transparent;
+ border-radius: 10rem;
+ }
+ &::-ms-thumb {
+ width: $o-we-sidebar-content-field-progress-control-height;
+ height: $o-we-sidebar-content-field-progress-control-height;
+ margin-top: 0;
+ margin-right: 0;
+ margin-left: 0;
+ border: none;
+ border-radius: 10rem;
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ box-shadow: none;
+ appearance: none;
+
+ &:active {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ }
+ &::-ms-track {
+ width: 100%;
+ height: $o-we-sidebar-content-field-progress-height;
+ cursor: pointer;
+ background-color: transparent;
+ border-color: transparent;
+ border-width: $o-we-sidebar-content-field-progress-control-height / 2;
+ box-shadow: none;
+ }
+ &::-ms-fill-lower {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ border-radius: 10rem;
+ @include border-radius($custom-range-track-border-radius);
+ }
+ &::-ms-fill-upper {
+ background-color: $o-we-sidebar-content-field-progress-color;
+ border-radius: 10rem;
+ }
+
+ &.o_we_inverted_range {
+ transform: rotate(180deg);
+
+ &::-moz-range-track {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ &::-moz-range-progress {
+ background-color: $o-we-sidebar-content-field-progress-color;
+ }
+ &::-ms-fill-lower {
+ background-color: $o-we-sidebar-content-field-progress-color;
+ }
+ &::-ms-fill-upper {
+ background-color: $o-we-sidebar-content-field-progress-active-color;
+ }
+ }
+ }
+ }
+
+ // Multi widgets
+ we-multi.o_we_user_value_widget {
+ margin-top: 0;
+
+ > div {
+ flex-flow: row wrap;
+
+ > * {
+ flex: 1 1 auto; // Needed for colorpicker...
+ }
+ }
+ }
+
+ //----------------------------------------------------------------------
+ // Layout Utils
+ //----------------------------------------------------------------------
+
+ we-row {
+ position: relative;
+ @extend %o-we-inline;
+ margin-top: $o-we-sidebar-content-field-spacing;
+
+ .o_we_user_value_widget {
+ margin-top: 0;
+ min-width: 4em; // Ideally rely on actual natural min-width, but does not work...
+ }
+ we-button, .o_we_so_color_palette {
+ &.o_we_user_value_widget {
+ min-width: auto; // ... except for these ones
+ }
+ }
+
+ > div {
+ display: flex;
+ align-items: center;
+
+ > :not(.d-none) ~ * {
+ margin-left: $o-we-sidebar-content-field-multi-spacing;
+ }
+ }
+
+ we-select.o_we_user_value_widget {
+ position: static;
+ }
+
+ &.o_we_full_row > div {
+ flex: 1 1 auto;
+ }
+ }
+
+ %o-we-inline {
+ display: flex;
+ flex-flow: row wrap;
+
+ > we-title {
+ width: 100%;
+ }
+ > div {
+ flex: 0 1 auto;
+ min-width: 0;
+ margin-top: $o-we-sidebar-content-field-spacing;
+ }
+
+ &:not(.o_we_fw) {
+ flex-flow: row nowrap;
+ align-items: center;
+
+ > we-title {
+ flex: 0 0 auto;
+ @include o-text-overflow();
+ width: $o-we-sidebar-content-field-label-width;
+ padding-right: $o-we-sidebar-content-field-label-spacing;
+ }
+ > div {
+ margin-top: 0;
+ }
+ }
+ }
+
+ we-collapse {
+ position: relative;
+ display: block;
+ padding-left: $o-we-sidebar-content-indent;
+ padding-right: $o-we-sidebar-content-padding-base;
+ margin-right: -$o-we-sidebar-content-padding-base;
+ margin-left: -$o-we-sidebar-content-indent;
+ // Allow inner margin to be considered inside the block + Visual space after/before previous/next widget + No flickering on opening
+ $-inner-spacing: ceil($o-we-sidebar-content-field-spacing / 2);
+ border-top: ($o-we-sidebar-content-field-spacing - $-inner-spacing) solid transparent;
+ padding-bottom: ($o-we-sidebar-content-field-spacing - $-inner-spacing);
+ margin-bottom: -($o-we-sidebar-content-field-spacing - $-inner-spacing);
+ background-clip: padding-box;
+
+ > :first-child, .o_we_collapse_toggler {
+ margin-top: $-inner-spacing;
+ }
+ .o_we_collapse_toggler {
+ @include o-position-absolute($top: 0, $left: 0);
+ width: $o-we-sidebar-content-indent;
+ height: $o-we-sidebar-content-field-height;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: none;
+ border: none;
+
+ &::after {
+ content: '\f0da';
+ position: static;
+ transform: none;
+ }
+ &.active {
+
+ &::after {
+ content: '\f0d7';
+ }
+ + * {
+ background: none;
+ border: none;
+ box-shadow: none;
+ }
+ }
+ }
+ &.active {
+ background-color: $o-we-sidebar-content-fold-block-bg;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-dark, .5), $o-we-item-standup-bottom rgba($o-we-item-standup-color-light, .2);
+
+ we-collapse.active, we-collapse.active .o_we_collapse_toggler {
+ background-color: $o-we-bg-lighter;
+ }
+ }
+ }
+
+ .o_we_image_weight {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ margin: $o-we-sidebar-content-field-spacing *.25 $o-we-item-border-width $o-we-sidebar-content-field-spacing*2;
+
+ b {
+ margin-left: $o-we-sidebar-content-field-label-spacing;
+ font: 1em/1 bold $o-we-sidebar-content-field-input-font-family;
+ color: $o-we-color-success;
+ }
+ }
+
+ .o_we_external_warning {
+ margin-top: $o-we-sidebar-content-field-spacing;
+ }
+
+ .o_we_tag {
+ padding: ($o-we-sidebar-content-field-label-spacing / 2) $o-we-sidebar-content-field-label-spacing;
+ border-radius: 5px;
+ background-color: $o-we-bg-darkest;
+
+ + .fa {
+ margin: 0 0 0 5px;
+ }
+ }
+ .o_we_tag_wrapper {
+ display: inline-flex;
+ margin: $o-we-sidebar-content-field-label-spacing ($o-we-sidebar-content-field-label-spacing / 2) 0 0;
+ }
+ .o_wblog_new_tag {
+ & div, & we-input {
+ width: 100% !important;
+ }
+ }
+ }
+
+ > .o_we_invisible_el_panel {
+ flex: 0 0 auto;
+ max-height: 220px;
+ overflow-y: auto;
+ margin-top: auto;
+ padding: $o-we-sidebar-blocks-content-spacing;
+ background-color: $o-we-sidebar-blocks-content-bg;
+ box-shadow: $o-we-item-standup-top rgba($o-we-item-standup-color-light, .2);
+
+ .o_panel_header {
+ padding: $o-we-sidebar-content-field-spacing 0;
+ }
+
+ .o_we_invisible_entry {
+ padding: $o-we-sidebar-content-field-spacing $o-we-sidebar-content-field-clickable-spacing;
+ cursor: pointer;
+
+ &:hover {
+ background-color: $o-we-sidebar-bg;
+ }
+ }
+ }
+
+ &.o_we_backdrop {
+ > .o_we_customize_panel {
+ // Ensure the panel takes full height so that an opened dropdown
+ // does not make a scrollbar appear for no reason
+ flex: 1 1 auto;
+
+ &::after {
+ content: "";
+ @include o-position-absolute(0, 0, 0, 0);
+ display: block;
+ pointer-events: none;
+ background: $o-we-sidebar-content-backdrop-bg;
+ }
+ }
+
+ .o_we_widget_opened {
+ z-index: $zindex-dropdown;
+ }
+ }
+}
+
+.o_we_cc_preview_wrapper {
+ @extend %o-preview-alpha-background;
+ font-family: sans-serif !important;
+ font-size: 15px !important;
+ padding: $o-we-sidebar-content-field-spacing $o-we-sidebar-content-field-spacing $o-we-sidebar-content-field-spacing*.8;
+}
+.o_we_cc_preview_wrapper > * {
+ margin-bottom: 0 !important;
+ line-height: 1 !important;
+}
+.o_we_color_combination_btn_text {
+ color: inherit !important;
+ font-family: inherit !important;
+ font-size: 0.8em !important;
+ margin-top: .5em!important;
+}
+.o_we_color_combination_btn_title {
+ margin-top: 0 !important;
+ font-size: 1.3em !important;
+}
+.o_we_color_combination_btn_btn {
+ padding: 0.2em 3px 0.3em !important;
+ border-radius: 2px !important;
+ font-size: 0.8em !important;
+}
+
+// SNIPPET OPTIONS
+.colorpicker {
+ background-color: $o-we-sidebar-content-field-colorpicker-dropdown-bg;
+ color: $o-we-sidebar-content-field-colorpicker-dropdown-color;
+
+ .o_we_colorpicker_switch_panel {
+ border-bottom: 1px solid $o-we-bg-dark;
+ box-shadow: inset 0 1px 0 rgba(white, .2);
+ }
+ .o_we_colorpicker_switch_pane_btn {
+ @extend %we-generic-tab-button;
+ flex: 0 0 auto;
+ }
+ .o_colorpicker_reset {
+ max-width: 40%;
+ @extend %we-generic-button;
+ }
+ .o_colorpicker_sections {
+ .o_colorpicker_widget {
+ .o_hex_div, .o_rgba_div {
+ &:focus-within {
+ border-color: $o-we-sidebar-content-field-input-border-color;
+ }
+ }
+ .o_color_picker_inputs {
+ input {
+ border: none;
+ &:focus {
+ outline: none;
+ }
+ }
+ }
+ }
+
+ .o_we_color_btn, .o_we_color_combination_btn {
+ float: left;
+ width: percentage(1 / 8);
+ padding-top: percentage(1 / 10);
+ margin: 0;
+ border: 1px solid $o-we-sidebar-content-field-colorpicker-dropdown-bg;
+ box-shadow: $o-we-sidebar-content-field-colorpicker-shadow;
+
+ &.o_colorpicker_reset {
+ background-color: transparent;
+
+ &::before {
+ @include o-position-absolute(0, 0, 0, 0);
+ font-family: FontAwesome !important;
+ content: "\f00d" !important;// fa-times
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $o-we-color-danger;
+ }
+ }
+ }
+ .o_we_color_combination_btn {
+ float: none;
+ width: 100%;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ background-color: transparent;
+ background-clip: padding-box;
+
+ // Borders instead of margins so that the user smoothly goes from
+ // one button to another without leaving them.
+ border-top: $o-we-sidebar-content-field-spacing solid transparent;
+ border-bottom: $o-we-sidebar-content-field-spacing solid transparent;
+
+ + .o_we_color_combination_btn {
+ margin-top: $o-we-sidebar-content-field-spacing * -.5;
+ }
+
+ &.selected {
+ > .o_we_cc_preview_wrapper {
+ box-shadow: 0 0 0 1px $o-we-color-success !important;
+ }
+ .o_we_color_combination_btn_title::before {
+ content: "\f00c";
+ margin-right: $o-we-sidebar-content-field-spacing;
+ font-size: 0.8em;
+ font-family: FontAwesome;
+ color: $o-we-color-success;
+ }
+ }
+
+ .o_we_cc_preview_wrapper:after {
+ // For some reasons, in this specific context we have to
+ // compensate a 1px gap between the flex container and the
+ // absolute positioned pseudo-element that generates the bg.
+ bottom: -1px;
+ }
+ }
+ .o_colorpicker_section {
+ padding-top: $o-we-sidebar-content-field-spacing;
+
+ &::after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+
+ .o_we_color_btn {
+ position: relative;
+
+ &.selected {
+ box-shadow: inset 0 0 0 1px $o-we-sidebar-content-field-colorpicker-dropdown-bg,
+ inset 0 0 0 3px $o-we-accent,
+ inset 0 0 0 4px white;
+ }
+
+ &.o_btn_transparent::before {
+ background-color: transparent;
+ }
+ }
+
+ &[data-name="transparent_grayscale"], &[data-name="theme"], &[data-name="reset"] {
+ .o_we_color_btn {
+ @extend %o-preview-alpha-background;
+
+ &::before, &::after {
+ box-shadow: inherit;
+ }
+ }
+ }
+ }
+ }
+}
+
+// DROPZONES
+@keyframes dropZoneInsert {
+ to {
+ background-color: rgba($o-brand-odoo, 0.3);
+ }
+}
+
+.oe_drop_zone {
+ background-color: rgba($o-brand-odoo, 0.15);
+ animation: dropZoneInsert 1s linear 0s infinite alternate;
+
+ &.oe_insert {
+ position: relative;
+ z-index: $o-we-overlay-zindex;
+ width: 100%;
+ min-width: $o-we-dropzone-size;
+ height: $o-we-dropzone-size;
+ min-height: $o-we-dropzone-size;
+ margin: (-$o-we-dropzone-size/2) 0;
+ border: 2px dashed $o-we-border-color;
+
+ &.oe_vertical {
+ width: $o-we-dropzone-size;
+ float: left;
+ margin: 0 (-$o-we-dropzone-size/2);
+ }
+ }
+}
+
+// MANIPULATORS
+#oe_manipulators {
+ position: relative;
+ z-index: $o-we-overlay-zindex;
+
+ // SNIPPET MANIPULATORS
+ .oe_overlay {
+ @include o-position-absolute;
+ display: none;
+ height: 0;
+ border-color: $o-we-handles-accent-color;
+ background: transparent;
+ text-align: center;
+ transition: opacity 400ms linear 0s;
+
+ &.o_overlay_hidden {
+ opacity: 0;
+ transition: none;
+ }
+ &.oe_active {
+ display: block;
+ z-index: 1;
+ }
+
+ // HANDLES
+ > .o_handles {
+ @include o-position-absolute(-$o-we-handles-offset-to-hide, 0, auto, 0);
+ border-color: inherit;
+
+ &:hover > .o_handle {
+ background-color: rgba($o-we-handles-accent-color, 0.05);
+ }
+ > .o_handle {
+ position: relative;
+ border: 0 solid transparent;
+ border-color: inherit;
+ transition: background 300ms ease 0s;
+
+ &.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);
+ height: $o-we-handle-edge-size;
+ 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);
+ height: $o-we-handle-edge-size;
+ 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;
+ }
+ }
+
+ &::after {
+ z-index: 1;
+ display: block;
+ width: $o-we-handles-btn-size;
+ height: $o-we-handles-btn-size;
+ border: solid 1px darken($o-we-handles-accent-color, 20%);
+ line-height: $o-we-handles-btn-size - 2;
+ font-size: 14px;
+ font-family: FontAwesome;
+ background-color: darken($o-we-handles-accent-color, 10%);
+ color: white;
+ }
+
+ &.o_handle_start:after {
+ background-color: rgba($o-we-sidebar-content-field-clickable-bg, .6);
+ border-color: rgba($o-we-sidebar-content-field-border-color, .2);
+ }
+
+ &:hover,
+ &.o_active {
+ background-color: rgba($o-we-handles-accent-color, 0.2);
+
+ &::after {
+ border-color: darken($o-we-handles-accent-color, 10%);
+ background-color: darken($o-we-handles-accent-color, 20%);
+ }
+ }
+
+ &.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';
+ }
+ }
+
+ &.readonly {
+ cursor: auto !important;
+
+ &:after {
+ display: none !important;
+ }
+
+ &:hover {
+ opacity: 0.5;
+ }
+ }
+ }
+
+ > .o_overlay_options_wrap {
+ @include o-position-absolute($o-we-handles-offset-to-hide, $left: 50%);
+ transform: translate(-50%, -110%);
+
+ &, > .o_overlay_move_options {
+ display: flex;
+ }
+ > .o_overlay_move_options {
+ > *, + * {
+ @extend %we-generic-button;
+ margin: 0 1px 0;
+ min-width: 22px;
+ padding: 0 $o-we-sidebar-content-field-button-group-button-spacing * .5;
+ color: $o-we-fg-lighter;
+ }
+ }
+ > .oe_snippet_remove {
+ margin-left: $o-we-sidebar-content-field-button-group-button-spacing;
+ background-color: mix($o-we-color-danger, $o-we-sidebar-content-field-clickable-bg);;
+ }
+ > .o_overlay_move_options > .o_move_handle {
+ cursor: move;
+ width: 30px;
+ height: 22px;
+ background-image: url('/web_editor/static/src/img/snippets_options/o_overlay_move_drag.svg');
+ background-position: center;
+ background-repeat: no-repeat;
+ }
+ &:hover {
+ > .o_overlay_move_options {
+ > *, + * {
+ @include o-hover-opacity(.6);
+
+ &:hover {
+ border-color: mix($o-we-handles-accent-color, $o-we-sidebar-content-field-pressed-bg, .4);
+ background-color: $o-we-sidebar-content-field-pressed-bg;
+ }
+ }
+ }
+ > .oe_snippet_remove:hover {
+ border-color: mix($o-we-color-danger, $o-we-sidebar-content-field-pressed-bg, .4);
+ background-color: $o-we-color-danger;
+ }
+ }
+ }
+ }
+
+ &.o_top_cover > .o_handles > .o_overlay_options_wrap {
+ top: auto;
+ bottom: -$o-we-handles-offset-to-hide;
+ transform: translate(-50%, 110%);
+ }
+
+ &.o_we_overlay_preview {
+ pointer-events: none;
+
+ > .o_handles {
+
+ > .o_handle::after, .o_overlay_options_wrap {
+ display: none;
+ }
+ }
+ }
+
+ // Background position overlay
+ &.o_we_background_position_overlay {
+ background-color: rgba(0,0,0,.7);
+ z-index: auto;
+
+ .o_we_overlay_content {
+ cursor: grab;
+
+ .o_we_grabbing {
+ cursor: grabbing;
+ }
+ }
+
+ .o_overlay_background > * {
+ display: block !important;
+ top: 0 !important;
+ right: 0 !important;
+ bottom: 0 !important;
+ left: 0 !important;
+ transform: none !important;
+ max-width: unset !important;
+ max-height: unset !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;
+}
+
+// CONTAINER PREVIEW
+.o_container_preview {
+ outline: 2px dashed $o-we-handles-accent-color;
+}
+
+we-select.o_we_shape_menu {
+ we-button[data-shape] {
+ padding: 0 !important;
+
+ &.active {
+ border: 1px solid $o-we-color-success !important;
+ }
+ div {
+ width: 100%;
+ }
+ .o_we_shape_btn_content {
+ @extend %o-preview-alpha-background;
+ width: 100%;
+ height: 75px;
+ }
+ }
+}
+
+.o_we_ui_loading {
+ @include o-position-absolute(0, 0, 0, 0);
+ z-index: $o-we-zindex;
+ background-color: $o-we-sidebar-content-backdrop-bg;
+ color: $o-we-fg-lighter;
+}
+#oe_manipulators > .o_we_ui_loading {
+ // hacky solution to be over the content, ideally that loader should only
+ // be over the content being reloaded (with a covering similar to the editor
+ // overlay covering).
+ position: fixed;
+ right: $o-we-sidebar-width;
+}
+
+.o_we_force_no_transition {
+ // Note: this is forced through a CSS class instead of inline style to avoid
+ // overridding existing inline styles or forgetting to restore them as the
+ // code evolves. We may need to increase the CSS priority of this. It will
+ // also not work to override important inline style... this is a limitation.
+ transition: none !important;
+}
diff --git a/addons/web_editor/static/src/xml/ace.xml b/addons/web_editor/static/src/xml/ace.xml
new file mode 100644
index 00000000..be9fe53d
--- /dev/null
+++ b/addons/web_editor/static/src/xml/ace.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates>
+
+<t t-name="web_editor.ace_view_editor">
+ <div class="o_ace_view_editor">
+ <div class="form-inline o_ace_view_editor_title">
+ <div class="btn-group o_ace_type_switcher">
+ <button type="button" class="btn btn-info dropdown-toggle" data-toggle="dropdown">XML (HTML)</button>
+ <div class="dropdown-menu" role="menu">
+ <a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="xml">XML (HTML)</a>
+ <a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="scss">SCSS (CSS)</a>
+ <a role="menuitem" href="#" class="dropdown-item o_ace_type_switcher_choice" data-type="js">JS</a>
+ </div>
+ </div>
+ <select id="ace-view-list" class="o_res_list"/>
+ <select id="ace-scss-list" class="o_res_list d-none"/>
+ <select id="ace-js-list" class="o_res_list d-none"/>
+ <label class="o_include_option oe_include_bundles">
+ <div class="dropdown">
+ <button class="btn btn-primary dropdown-toggle" type="button" id="o_dropdown_filter_views" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ Filter
+ </button>
+ <div class="dropdown-menu" aria-labelledby="o_dropdown_filter_views">
+ <a class="dropdown-item o_ace_filter active" data-type="xml" data-value="default" href="#">Only Views</a>
+ <a class="dropdown-item o_ace_filter" data-type="xml" data-value="all" href="#">Views and Assets bundles</a>
+ </div>
+ </div>
+ </label>
+ <label class="o_include_option o_include_all_scss d-none">
+ <div class="dropdown">
+ <button class="btn btn-primary dropdown-toggle" type="button" id="o_dropdown_filter_assets" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+ Filter
+ </button>
+ <div class="dropdown-menu" aria-labelledby="o_dropdown_filter_assets">
+ <a class="dropdown-item o_ace_filter active" data-type="scss" data-value="custom" href="#">Only Custom SCSS Files</a>
+ <a class="dropdown-item o_ace_filter" data-type="scss" data-value="restricted" href="#">Only Page SCSS Files</a>
+ <a class="dropdown-item o_ace_filter" data-type="scss" data-value="all" href="#">All SCSS Files</a>
+ </div>
+ </div>
+ </label>
+ <div class="o_button_section">
+ <button data-action="save" type="submit" class="btn btn-primary">Save</button>
+ <button data-action="close" type="button" class="btn btn-secondary">Close</button>
+ </div>
+ </div>
+ <div id="ace-view-id">
+ <div class="float-right mb-2">
+ <button data-action="reset" type="button" class="btn btn-sm btn-danger"><i class="fa fa-undo"/> Reset</button>
+ <button data-action="format" type="button" class="btn btn-sm btn-link">Format</button>
+ </div>
+ <span class="o_ace_editor_resource_info"/>
+ <div class="alert alert-warning alert-dismissible mt-2 mb-0" role="alert">
+ Editing a built-in file through this editor is not advised, as it will prevent it from being updated during future App upgrades.
+ <button type="button" class="close" aria-label="Close">
+ <span>×</span>
+ </button>
+ </div>
+ </div>
+ <div id="ace-view-editor"/>
+ </div>
+</t>
+
+</templates>
diff --git a/addons/web_editor/static/src/xml/backend.xml b/addons/web_editor/static/src/xml/backend.xml
new file mode 100644
index 00000000..c79ee939
--- /dev/null
+++ b/addons/web_editor/static/src/xml/backend.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <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">
+ <span class="fa fa-language fa-lg oe_input_icon"/>
+ </button>
+ </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
new file mode 100644
index 00000000..ee766a81
--- /dev/null
+++ b/addons/web_editor/static/src/xml/editor.xml
@@ -0,0 +1,42 @@
+<?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-toolbars"/>
+ </t>
+
+ <!--=================-->
+ <!-- Snippet options -->
+ <!--=================-->
+
+ <!-- Background position option overlay -->
+ <t t-name="web_editor.background_position_overlay">
+ <div class="o_we_background_position_overlay oe_overlay">
+ <div class="o_we_overlay_content position-absolute">
+ <div class="o_overlay_background"/>
+ <div class="o_we_overlay_buttons position-absolute d-flex m-1" style="top: 0">
+ <button class="btn btn-primary mr-1 o_btn_apply">Apply</button>
+ <button class="btn btn-danger o_btn_discard">Discard</button>
+ </div>
+ </div>
+ </div>
+ </t>
+ <!-- Save Snippet Name option dialog -->
+ <t t-name="web_editor.dialog.save_snippet">
+ <div>
+ <div class="form-group form-row">
+ <label class="col-form-label col-md-3" for="snippetName">Name</label>
+ <div class="col-md-9">
+ <input type="text"
+ class="form-control o_we_snippet_name_input"
+ id="snippetName"
+ t-att-value="currentSnippetName"/>
+ </div>
+ </div>
+ </div>
+ </t>
+</templates>
diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml
new file mode 100644
index 00000000..0d833333
--- /dev/null
+++ b/addons/web_editor/static/src/xml/snippets.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+ <t t-name="web_editor.snippet_overlay">
+ <div class="oe_overlay">
+ <div class="o_handles">
+ <!-- Visible overlay borders + allow to resize when not readonly -->
+ <div class="o_handle n readonly"/>
+ <div class="o_handle e readonly"/>
+ <div class="o_handle w readonly"/>
+ <div class="o_handle s readonly"/>
+
+ <div class="o_overlay_options_wrap">
+ <!-- Overlay move specific section -->
+ <div class="o_overlay_move_options">
+ <!-- Button-like handle to drag and drop -->
+ <div class="o_move_handle"/>
+ </div>
+ <button type="button" class="oe_snippet_remove fa fa-trash"/>
+ </div>
+ </div>
+ </div>
+ </t>
+ <t t-name="web_editor.customize_block_options_section">
+ <we-customizeblock-options>
+ <we-title>
+ <span t-esc="name"/>
+ <we-top-button-group>
+ <we-button class="fa fa-fw fa-clone oe_snippet_clone o_we_link o_we_hover_success"
+ title="Duplicate Container"
+ aria-label="Duplicate Container"/>
+ <we-button class="fa fa-fw fa-trash oe_snippet_remove o_we_link o_we_hover_danger"
+ title="Remove Block"
+ aria-label="Remove Block"/>
+ </we-top-button-group>
+ </we-title>
+ </we-customizeblock-options>
+ </t>
+ <t t-name="web_editor.outdated_block_message">
+ <we-alert>
+ <we-title>This block is outdated</we-title>
+ <span>To make changes, drop this block and use the new options in the last version.</span>
+ </we-alert>
+ </t>
+
+ <!-- options -->
+ <div t-name="web_editor.snippet.option.colorpicker" class="colorpicker">
+ <div class="o_we_colorpicker_switch_panel d-flex justify-content-end px-2">
+ <t t-if="widget.withCombinations">
+ <button type="button" tabindex="1" class="o_we_colorpicker_switch_pane_btn active" data-target="color-combinations">
+ <span>Combinations</span>
+ </button>
+ <button type="button" tabindex="2" t-attf-class="o_we_colorpicker_switch_pane_btn #{widget.withCombinations ? '' : 'active'}" data-target="theme-colors">
+ <span>Custom</span>
+ </button>
+ </t>
+ <button t-if="widget.resetButton" type="button" class="my-1 ml-auto o_we_color_btn o_colorpicker_reset">
+ <t t-if="widget.withCombinations">None</t>
+ <t t-else="">Reset</t>
+ </button>
+ </div>
+ <div t-attf-class="o_colorpicker_sections #{widget.withCombinations ? '' : 'd-none'} pt-2 px-2 pb-3 bg-black-25" data-color-tab="color-combinations">
+ <!-- List all Presets -->
+ <t t-foreach="5" t-as="i">
+ <t t-call="web_editor.colorpalette.combination.btn">
+ <t t-set="number" t-value="i + 1"/>
+ </t>
+ </t>
+ </div>
+ <div t-attf-class="o_colorpicker_sections #{widget.withCombinations ? 'd-none' : ''} py-3 px-2 bg-black-25" data-color-tab="theme-colors"/>
+ </div>
+ <t t-name="web_editor.colorpalette.combination.btn">
+ <button type="button" class="o_we_color_btn o_we_color_combination_btn"
+ t-att-data-color="number" t-attf-title="Preset #{number}">
+ <t t-call="web_editor.color.combination.preview"/>
+ </button>
+ </t>
+ <t t-name="web_editor.color.combination.preview">
+ <div class="o_we_cc_preview_wrapper d-flex justify-content-between">
+ <h1 class="o_we_color_combination_btn_title">Title</h1>
+ <p class="o_we_color_combination_btn_text flex-grow-1">Text</p>
+ <span class="o_we_color_combination_btn_btn btn btn-sm btn-primary o_btn_preview mr-1"><small>Button</small></span>
+ <span class="o_we_color_combination_btn_btn btn btn-sm btn-secondary o_btn_preview"><small>Button</small></span>
+ </div>
+ </t>
+
+ <t t-name="web_editor.many2one.button">
+ <div class="btn-group">
+ <a role="button" href="#" class="btn btn-secondary dropdown-toggle d-none" data-toggle="dropdown" data-hover="dropdown" title="Search Contact" aria-label="Search Contact">
+ <i class="fa fa-search"></i>
+ </a>
+ <ul class="dropdown-menu contact_menu d-block list-group list-group-flush mx-1" role="menu">
+ <li class="px-1"><a role="menuitem" class="dropdown-item pl-1 pr-2"><i class="fa fa-search"></i><input href="#" type="email" placeholder="Search" class="ml-2" autocomplete="off"/></a></li>
+ </ul>
+ </div>
+ </t>
+
+ <t t-name="web_editor.many2one.search">
+ <t t-foreach="contacts" t-as="item">
+ <li class="list-group-item px-2"><a role="menuitem" href="#" t-att-data-id="item.id" t-att-data-name="item.display_name"><t t-esc="item.display_name"/> <t t-if="item.city or item.country_id"><small class="text-muted">(<t t-esc="item.city"/> <t t-esc="item.country_id and item.country_id[1]"/>)</small></t></a></li>
+ </t>
+ </t>
+</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 00000000..3ca5030a
--- /dev/null
+++ b/addons/web_editor/static/src/xml/wysiwyg.xml
@@ -0,0 +1,579 @@
+<?xml version="1.0" encoding="utf-8"?>
+<templates id="template" xml:space="preserve">
+
+ <!--=================-->
+ <!-- Edition Iframe -->
+ <!--=================-->
+
+ <t t-name="wysiwyg.iframeContent"><head>
+ <meta charset="utf-8"/>
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
+ <t t-foreach="assets || []" t-as="asset">
+ <t t-foreach="asset.cssLibs || []" t-as="cssLib">
+ <link type="text/css" rel="stylesheet" t-att-href="cssLib"/>
+ </t>
+ <t t-foreach="asset.cssContents || []" t-as="cssContent">
+ <style type="text/css" t-raw="cssContent"/>
+ </t>
+ <t t-foreach="asset.jsLibs || []" t-as="jsLib">
+ <script type="text/javascript" t-att-src="jsLib"/>
+ </t>
+ <t t-foreach="asset.jsContents || []" t-as="jsContent" t-if="jsContent.indexOf('inline asset') !== -1">
+ <script type="text/javascript" t-raw="jsContent"/>
+ </t>
+ </t>
+ <script type="text/javascript">
+ odoo.define('web.session', function () {
+ return window.top.odoo.__DEBUG__.services['web.session'];
+ });
+
+ odoo.define('root.widget', function (require) {
+ 'use strict';
+ var Widget = require('web.Widget');
+ var widget = new Widget();
+ widget.appendTo(document.body);
+ return widget;
+ });
+
+ odoo.define('web.core.top', function (require) {
+ var core = require('web.core');
+ core.qweb.templates = window.top.odoo.__DEBUG__.services['web.core'].qweb.templates;
+ });
+ </script>
+ </head>
+ <body id="iframe_target" class="o_in_iframe">
+ <div id="web_editor-top-edit"></div>
+ <div id="wrapwrap">
+ <main>
+ <div data-oe-model="model" data-oe-type="html" class="o_editable oe_structure"></div>
+ </main>
+ </div>
+ <script type="text/javascript">
+ odoo.define('web_editor.wysiwyg.iniframe', function (require) {
+ 'use strict';
+ var editor = require('web_editor.editor');
+
+ window._summernoteSlave = $.summernote;
+ window._summernoteSlave.iframe = true;
+ window._summernoteSlave.lang = window.top.$.summernote.lang;
+ if (window.top.<t t-esc="updateIframeId"/>) {
+ window.top.<t t-esc="updateIframeId"/>(editor.Class, <t t-esc="avoidDoubleLoad"/>);
+ }
+ });
+ </script>
+ </body>
+ </t>
+
+ <!--=================-->
+ <!-- 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 t-if="!widget.options.noImages" class="nav-item"><a t-attf-class="nav-link #{widget.isImageActive() ? 'active' : ''}" id="editor-media-image-tab" data-toggle="tab" href="#editor-media-image" role="tab" aria-controls="editor-media-image" t-att-aria-selected="widget.isImageActive().toString()">Image</a></li>
+ <li t-if="!widget.options.noDocuments" class="nav-item"><a t-attf-class="nav-link #{widget.isDocumentActive() ? 'active' : ''}" id="editor-media-document-tab" data-toggle="tab" href="#editor-media-document" role="tab" aria-controls="editor-media-document" t-att-aria-selected="widget.isDocumentActive().toString()">Document</a></li>
+ <li t-if="!widget.options.noIcons" class="nav-item"><a t-attf-class="nav-link #{widget.isIconActive() ? 'active' : ''}" id="editor-media-icon-tab" data-toggle="tab" href="#editor-media-icon" role="tab" aria-controls="editor-media-icon" t-att-aria-selected="widget.isIconActive().toString()">Pictogram</a></li>
+ <li t-if="!widget.options.noVideos" class="nav-item"><a t-attf-class="nav-link #{widget.isVideoActive() ? 'active' : ''}" id="editor-media-video-tab" data-toggle="tab" href="#editor-media-video" role="tab" aria-controls="editor-media-video" t-att-aria-selected="widget.isVideoActive().toString()">Video</a></li>
+ </ul>
+ <!-- Tab panes -->
+ <div class="tab-content">
+ <div t-if="!widget.options.noImages" t-attf-class="tab-pane fade #{widget.isImageActive() ? 'show active': ''}" id="editor-media-image" role="tabpanel" aria-labelledby="editor-media-image-tab"/>
+ <div t-if="!widget.options.noDocuments" t-attf-class="tab-pane fade #{widget.isDocumentActive() ? 'show active': ''}" id="editor-media-document" role="tabpanel" aria-labelledby="editor-media-document-tab"/>
+ <div t-if="!widget.options.noIcons" t-attf-class="tab-pane fade #{widget.isIconActive() ? 'show active': ''}" id="editor-media-icon" role="tabpanel" aria-labelledby="editor-media-icon-tab"/>
+ <div t-if="!widget.options.noVideos" t-attf-class="tab-pane fade #{widget.isVideoActive() ? 'show active': ''}" id="editor-media-video" role="tabpanel" aria-labelledby="editor-media-video-tab"/>
+ </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>
+
+ <!-- File choosing part of the Media Dialog -->
+ <t t-name="wysiwyg.widgets.file">
+ <form>
+ <t t-call="wysiwyg.widgets.files.submenu"/>
+ <div class="form-text o_we_error_text"/>
+ <div class="o_we_existing_attachments"/>
+ <div class="mt-4 text-center mx-auto o_we_load_more">
+ <button class="btn btn-primary o_load_more d-none" type="button">Load more...</button>
+ <div class="mt-4 o_load_done_msg d-none">
+ <span><i>All images have been loaded</i></span>
+ </div>
+ </div>
+ </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="widget.options.accept" t-att-multiple="widget.options.multiImages &amp;&amp; 'multiple'"/>
+
+ <div class="btn-group">
+ <button type="button" class="btn btn-primary o_upload_media_button">
+ <t t-esc="uploadText"/>
+ </button>
+ </div>
+
+ <div class="input-group align-items-center ml-2">
+ <input type="text" class="form-control o_we_url_input o_we_horizontal_collapse o_we_transition_ease" name="url" t-att-placeholder="urlPlaceholder"/>
+ <div class="input-group-append align-items-center">
+ <button type="button" class="btn btn-secondary o_upload_media_url_button">
+ <t t-esc="addText"/>
+ </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" t-att-title="urlWarningTitle"/>
+ <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-raw="0"/>
+ <t t-call="wysiwyg.widgets.media.search"/>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image">
+ <t t-call="wysiwyg.widgets.file">
+ <t t-set="uploadText">Upload an image</t>
+ <t t-set="urlPlaceholder">https://www.odoo.com/logo.png</t>
+ <t t-set="addText">Add URL</t>
+ <t t-set="searchPlaceholder">Search an image</t>
+ <t t-set="urlWarningTitle">The URL does not contain any image. The file will be added in the document section.</t>
+ <div class="d-flex justify-content-end flex-grow-1 pr-3">
+ <div t-attf-class="custom-control custom-switch #{__debug__ and 'd-flex' or 'd-none'} align-items-center mr-2">
+ <input class="o_we_show_optimized ml-2 custom-control-input" type="checkbox" id="o_we_show_optimized_switch"/>
+ <label class="custom-control-label" for="o_we_show_optimized_switch">
+ Show optimized images
+ </label>
+ </div>
+ <select class="custom-select o_we_search_select">
+ <option value="all">All</option>
+ <option value="database">My Images</option>
+ <option t-if="widget.options.useMediaLibrary" value="media-library">Illustrations</option>
+ </select>
+ </div>
+ </t>
+ </t>
+
+ <t t-name="wysiwyg.widgets.document">
+ <t t-call="wysiwyg.widgets.file">
+ <t t-set="uploadText">Upload a document</t>
+ <t t-set="urlPlaceholder">https://www.odoo.com/mydocument</t>
+ <t t-set="addText">Add document</t>
+ <t t-set="searchPlaceholder">Search a document</t>
+ <t t-set="urlWarningTitle">The URL contains an image. The file will be added in the image section.</t>
+ </t>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.optimize">
+ <form class="o_we_image_optimize_dialog">
+ <div class="row">
+ <div class="o_we_config_column col-lg-6">
+ <div class="form-group">
+ <label class="o_we_title_label" for="o_we_name_input">
+ Name
+ <i class="fa fa-question-circle-o" title="Give a relevant name to your file to optimize search engine results."/>
+ </label>
+ <input type="text" class="form-control" id="o_we_name_input" name="filename" aria-describedby="nameHelp" t-att-value="widget.attachment.name" required="required"/>
+ </div>
+
+ <small t-if="widget.disableResize" class="form-text text-muted o_we_no_resize">
+ <span class="fa fa-info-circle"/> Resizing is not supported for images of type <t t-esc="widget.attachment.mimetype"/>.
+ </small>
+ <div t-else="1" class="form-group">
+ <label class="o_we_title_label" for="o_we_name_input">
+ Size
+ <i class="fa fa-question-circle-o" title="Reduce the size as much as possible to increase performance."/>
+ </label>
+ <div class="form-row align-items-center">
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <div class="input-group-text">Width</div>
+ </div>
+ <input type="number" class="form-control" id="o_we_width" name="width" aria-describedby="sizeHelp" min="1" t-att-max="widget.image_width" t-att-value="widget.defaultWidth"/>
+ <div class="input-group-append">
+ <div class="input-group-text">px</div>
+ </div>
+ </div>
+ </div>
+ <div class="col-auto">
+ <i class="fa fa-times"/>
+ </div>
+ <div class="col">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <div class="input-group-text">Height</div>
+ </div>
+ <input type="number" class="form-control" id="o_we_height" name="height" aria-describedby="sizeHelp" min="1" t-att-max="widget.image_height" t-att-value="widget.defaultHeight"/>
+ <div class="input-group-append">
+ <div class="input-group-text">px</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="form-text small text-right">
+ <i class="fa fa-info-circle text-info"/>
+ <span>Or choose a preset:</span>
+ <t t-foreach="widget.suggestedWidths" t-as="suggestedWidth">
+ <span t-if="suggestedWidth_index > 0">-</span>
+ <a href="#" class="o_we_width_preset" t-att-data-width="suggestedWidth.width" t-esc="suggestedWidth.text"/>
+ </t>
+ </div>
+ </div>
+
+ <small t-if="widget.disableQuality" class="form-text text-muted o_we_no_quality">
+ <span class="fa fa-info-circle"/> Changing the quality is not supported for images of type <t t-esc="widget.attachment.mimetype"/>.
+ </small>
+ <div class="form-group" t-else="1">
+ <t t-if="widget.toggleQuality">
+ <div class="custom-control custom-switch">
+ <input type="checkbox" class="custom-control-input" id="o_we_optimize_quality" t-att-checked="widget.isExisting ? undefined : 'checked'" aria-describedby="toggleQualityHelp"/>
+ <label class="custom-control-label" for="o_we_optimize_quality">
+ Optimize
+ <i class="fa fa-question-circle-o" title="This reduces the quality to increase performance."/>
+ </label>
+ </div>
+ </t>
+ <t t-else="1">
+ <label class="o_we_title_label" for="o_we_quality_input">
+ Quality
+ <i class="fa fa-question-circle-o" title="Reduce the quality as much as possible to increase performance."/>
+ </label>
+ <div class="form-row align-items-center">
+ <div class="col-sm-10">
+ <input type="range" class="custom-range align-middle o_we_quality_range" id="quality_range" name="quality_range" min="1" max="100" step="1" aria-describedby="rangeQualityHelp" t-att-value="widget.defaultQuality"/>
+ </div>
+ <div class="col-sm-2">
+ <input type="number" class="form-control" id="o_we_quality_input" name="quality" min="1" max="100" step="1" aria-describedby="rangeQualityHelp" t-att-value="widget.defaultQuality"/>
+ </div>
+ </div>
+ </t>
+ </div>
+ </div>
+ <div class="o_we_preview_column col-lg-6">
+ <h4>Preview</h4>
+ <div class="mw-100 o_we_preview_area">
+ <img class="img o_we_preview_image" alt="Image Preview"/>
+ </div>
+ </div>
+ </div>
+ </form>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.existing.attachments">
+ <div class="o_we_existing_attachments o_we_images d-flex flex-wrap w-100 justify-content-between align-items-stretch my-0">
+ <t t-if="!widget.hasContent()">
+ <div t-if="widget.needle" class="o_nocontent_help">
+ <p class="o_empty_folder_image">No images found.</p>
+ <p class="o_empty_folder_subtitle">You can upload images with the button located in the top left of the screen.</p>
+ </div>
+ <div t-else="" class="o_we_search_prompt">
+ <h2>Get the perfect image by searching in our library of copyright free photos and illustrations.</h2>
+ </div>
+ </t>
+ <t t-else="">
+ <t t-if="['all', 'database'].includes(widget.searchService)" t-foreach="attachments" t-as="attachment">
+ <t t-call="wysiwyg.widgets.image.existing.attachment"/>
+ </t>
+ <t t-if="['all', 'media-library'].includes(widget.searchService)" t-foreach="libraryMedia" t-as="media">
+ <t t-call="wysiwyg.widgets.image.library_media"/>
+ </t>
+ <!-- 20 placeholders is just enough for a 5K screen, change this if ImageWidget.MIN_ROW_HEIGHT changes -->
+ <t t-foreach="20">
+ <div class="o_we_attachment_placeholder"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.existing.attachment">
+ <t t-set="isOptimized" t-value="!!attachment.original_id"/>
+ <div t-attf-class="o_existing_attachment_cell position-relative bg-light #{isOptimized and 'o_we_attachment_optimized d-none' or ''} align-items-center justify-content-center" t-att-data-id="attachment.id">
+ <t t-call="wysiwyg.widgets.file.existing.remove"/>
+ <div class="o_we_media_dialog_img_wrapper">
+ <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{attachment.thumbnail_src or attachment.image_src}" t-att-alt="attachment.name" t-att-title="attachment.name"/>
+ </div>
+ <span t-if="isOptimized" class="badge badge-success">Optimized</span>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.image.library_media">
+ <div t-attf-class="o_existing_attachment_cell position-relative bg-light align-items-center justify-content-center o_library_media_cell" t-att-data-media-id="media.id">
+ <div class="o_we_media_dialog_img_wrapper">
+ <img class="img img-fluid o_we_attachment_highlight" t-attf-src="#{media.thumbnail_url}" t-att-title="media.tooltip or ''" crossorigin="anonymous"/>
+ <a t-if="media.author" class="o_we_media_author" t-att-href="media.author_link" target="_blank" t-esc="media.author"/>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.document.existing.attachments">
+ <div class="o_we_existing_attachments o_we_documents">
+ <div t-if="!attachments.length" class="o_nocontent_help">
+ <p class="o_empty_folder_image">No documents found.</p>
+ <p class="o_empty_folder_subtitle">You can upload documents with the button located in the top left of the screen.</p>
+ </div>
+ <div t-else="" class="row mx-auto">
+ <t t-foreach="attachments" t-as="attachment">
+ <div class="col-2 o_existing_attachment_cell o_we_attachment_highlight my-2" t-att-data-id="attachment.id">
+ <t t-call="wysiwyg.widgets.file.existing.remove"/>
+
+ <div t-att-data-url="attachment.url" role="img" t-att-aria-label="attachment.name" t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype" t-attf-class="o_image d-flex align-items-center justify-content-center"/>
+ <small class="o_file_name" t-esc="attachment.name"/>
+ </div>
+ </t>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="wysiwyg.widgets.file.existing.remove">
+ <t t-set="removeTitle" t-if="attachment.res_model === 'ir.ui.view'">This file is a public view attachment.</t>
+ <t t-set="removeTitle" t-else="">This file is attached to the current record.</t>
+ <i class="fa fa-trash o_existing_attachment_remove p-2" t-att-title="removeTitle" role="img" t-att-aria-label="removeTitle"/>
+ </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>
+ </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>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_youtube_option o_vimeo_option o_dailymotion_option">
+ <label class="o_switch mb0">
+ <input id="o_video_autoplay" type="checkbox"/><span/>
+ <span style="margin-right: 8px;">Autoplay</span>
+ <span class="small text-muted" style="margin-left: auto;">Videos are muted when autoplay is enabled</span>
+ </label>
+ </div>
+ <div class="o_youtube_option o_vimeo_option">
+ <label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label>
+ </div>
+ <div class="o_youtube_option o_dailymotion_option">
+ <label class="o_switch mb0"><input id="o_video_hide_controls" type="checkbox"/><span/>Hide player controls</label>
+ </div>
+ <div class="o_youtube_option">
+ <label class="o_switch mb0"><input id="o_video_hide_fullscreen" type="checkbox"/><span/>Hide fullscreen button</label>
+ </div>
+ <div class="o_youtube_option">
+ <label class="o_switch mb0"><input id="o_video_hide_yt_logo" type="checkbox"/><span/>Hide Youtube logo</label>
+ </div>
+ <div class="o_dailymotion_option">
+ <label class="o_switch mb0"><input id="o_video_hide_dm_logo" type="checkbox"/><span/>Hide Dailymotion logo</label>
+ </div>
+ <div class="o_dailymotion_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 id="o_url_input" t-attf-class="form-group row o_url_input#{widget.isButton ? ' d-none' : ''}">
+ <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 class="form-check o_strip_domain d-none">
+ <input type="checkbox" id="o_link_dialog_url_strip_domain" checked="checked" class="form-check-input"/>
+ <label for="o_link_dialog_url_strip_domain" class="form-check-label font-weight-normal">
+ Autoconvert to relative link
+ </label>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="col-form-label col-md-3">Type</label>
+ <div class="col-md-9 d-flex align-items-center">
+ <div t-attf-class="#{widget.colorCombinationClass ? ('p-2 ' + widget.colorCombinationClass) : ''}">
+ <t t-foreach="widget.colorsData" t-as="colorData">
+ <label role="button" class="m-0 mr-2">
+ <input type="radio" name="link_style_color" class="d-none link-style" t-att-value="colorData.type"/>
+ <span t-esc="colorData.label"
+ t-attf-class="o_btn_preview btn btn-sm btn-#{colorData.btnPreview} #{colorData.type ? '' : 'px-0'}"/>
+ </label>
+ </t>
+ </div>
+ </div>
+ </div>
+ <div id="o_link_dialog_button_opts_collapse" class="collapse">
+ <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="rounded-circle">Default + Rounded</option>
+ <option value="outline">Outline</option>
+ <option value="outline,rounded-circle">Outline + Rounded</option>
+ <option value="fill">Fill</option>
+ <option value="fill,rounded-circle">Fill + Rounded</option>
+ <option value="flat">Flat</option>
+ </select>
+ </div>
+ </div>
+ </div>
+ <div t-if="!widget.isButton" 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 t-attf-class="#{widget.colorCombinationClass ? ('p-2 ' + widget.colorCombinationClass) : ''}"
+ 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>
+
+ <!-- ImageCropWidget controls (allows to crop images on the page) -->
+ <div t-name="wysiwyg.widgets.crop" class="o_we_crop_widget" contenteditable="false">
+ <div class="o_we_cropper_wrapper">
+ <img class="o_we_cropper_img"/>
+ <div class="o_we_crop_buttons text-center mt16 position-absolute o_we_no_overlay" contenteditable="false">
+ <div class="btn-group btn-group-toggle" title="Aspect Ratio" data-toggle="buttons">
+ <t t-foreach="widget.aspectRatios" t-as="ratio">
+ <t t-set="is_active" t-value="ratio === widget.aspectRatio"/>
+ <label t-attf-class="btn #{is_active and 'active' or ''}" data-action="ratio" t-att-data-value="ratio">
+ <input type="radio" /><t t-esc="ratio_value.label"/>
+ </label>
+ </t>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Zoom In" data-action="zoom" data-value="0.1"><i class="fa fa-fw fa-search-plus"/></button>
+ <button type="button" title="Zoom Out" data-action="zoom" data-value="-0.1"><i class="fa fa-fw fa-search-minus"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Rotate Left" data-action="rotate" data-value="-90"><i class="fa fa-fw fa-rotate-left"/></button>
+ <button type="button" title="Rotate Right" data-action="rotate" data-value="90"><i class="fa fa-fw fa-rotate-right"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Flip Horizontal" data-action="flip" data-scale-direction="scaleX"><i class="fa fa-fw fa-arrows-h"/></button>
+ <button type="button" title="Flip Vertical" data-action="flip" data-scale-direction="scaleY"><i class="fa fa-fw fa-arrows-v"/></button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Reset Image" data-action="reset"><i class="fa fa-refresh"/> Reset Image</button>
+ </div>
+ <div class="btn-group" role="group">
+ <button type="button" title="Apply" data-action="apply" class="btn btn-primary"><i class="fa fa-check"/> Apply</button>
+ <button type="button" title="Discard" data-action="discard" class="btn btn-danger"><i class="fa fa-times"/> Discard</button>
+ </div>
+ </div>
+ </div>
+ </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 00000000..a536f6f9
--- /dev/null
+++ b/addons/web_editor/static/src/xml/wysiwyg_colorpicker.xml
@@ -0,0 +1,33 @@
+<?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>
+</templates>