',
+ ];
+ $(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 = $('').insertAfter($button.parent());
+ $(tplIconButton('fa fa-refresh', {
+ title: _t('Spin'),
+ event: 'imageShape',
+ value: 'fa-spin'
+ })).appendTo($spin);
+
+ // resize for fa
+ var $resizefa = $('')
+ .insertAfter($imagePopover.find('.btn-group:has([data-event="resize"])'));
+ for (var size=1; size<=5; size++) {
+ $(tplButton(''+size+'x', {
+ 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 = $('');
+ $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 = $('');
+ $alt.appendTo($imagePopover.find('.popover-body'));
+ $alt.append('');
+
+ //////////////// 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(/"/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
')[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 = $('').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}
+ */
+ 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('');
+ 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(
+ $('', {
+ 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 = $('', {
+ 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($('').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($('').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($('').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($('').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($('').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 = $('', {
+ class: 'o_we_invisible_entry d-flex align-items-center justify-content-between',
+ text: editor.getName(),
+ }).append($('', {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}
+ * (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 = $(`
+
+
+ ${name}
+
+ `);
+ $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($('', {
+ 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}
+ */
+ _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 = $('', {
+ '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: $('', {text: _.str.sprintf(_t("Do you want to install the %s App?"), name)}).append(
+ $('', {
+ 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 %s"), 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: $('
' + _.str.sprintf(_t("Are you sure you want to delete the snippet: %s ?"), $snippet.attr('name')) + '
'),
+ 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 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}
+ */
+ 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}
+ */
+ async _checkIfWidgetsUpdateNeedReload(widgets) {
+ return false;
+ },
+ /**
+ * @private
+ * @returns {Promise|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}
+ */
+ _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}
+ */
+ _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>} [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('');
+ 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(`${label}`);
+ });
+
+ 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($(``)[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 with a bunch of
+ // inline styles "remembering" where they come from.
+ // chances are we had e.g.
+ //
foo
+ //
bar
+ // merged the lines getting this in webkit
+ //
foobar
+ 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 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")[0];
+ node.appendChild(sc);
+ sc = br;
+ so = 0;
+ } else if (!ancestor.children.length && !ancestor.textContent.match(/\S|\u00A0/)) {
+ sc = $(" ")[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 = $('').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 + '' +
+ ' ' + v +
+ '';
+ }, '');
+
+ var sLabel = '11';
+ return renderer.getTemplate().button(sLabel, {
+ title: lang.font.size,
+ dropdown: '
' + items + '
'
+ });
+};
+
+renderer.tplButtonInfo.color = function (lang, options) {
+ var foreColorButtonLabel = '';
+ var backColorButtonLabel = '';
+ // 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('