From 0dd1f4595bc1a4b56eff8f9acd62ffcba78fac76 Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Fri, 8 Nov 2019 13:01:55 +0000 Subject: [PATCH 1/8] [IMP] web_editor: add new way to edit data-* on a target Now, the editor UI can suggest options the same way as select-class but for editing a data attribute. Note: the public widgets are now refreshed on data attribute changes through the select-data-attribute and set-data-attribute methods. Part of https://github.com/odoo/odoo/pull/40282 In preparation of task-2122935 and other tasks --- .../static/src/js/editor/snippets.options.js | 38 +++++++++++++++ .../static/src/js/editor/snippets.options.js | 47 ++++++++----------- addons/website/views/snippets.xml | 14 +++--- .../static/src/js/s_latest_posts_editor.js | 37 --------------- addons/website_blog/views/snippets.xml | 24 +++++----- 5 files changed, 77 insertions(+), 83 deletions(-) diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 7667291ca0b..509fa04089f 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -294,6 +294,27 @@ var SnippetOption = Widget.extend({ this.$target.addClass(value); } }, + /** + * Default option method which allows to select one and only one value in + * the option values set and set it on the associated snippet as a data + * attribute. The name of the data attribute is given by the closest + * ancestor's data value for 'attributeName'. + * + * @param {boolean} previewMode - @see this.selectClass + * @param {Object} value - the data attribute value to set + * @param {jQuery} $opt - the related DOMElement option + */ + selectDataAttribute: function (previewMode, value, $opt) { + const $ancestor = $opt && $opt.closest('[data-attribute-name]'); + if (!$ancestor || !$ancestor.length) { + return; + } + const dataName = $ancestor[0].dataset.attributeName; + if (!dataName) { + return; + } + this.$target[0].dataset[dataName] = value || ''; + }, /** * Default option method which allows to select one or multiple classes in * the option classes set and set it on the associated snippet. The common @@ -528,6 +549,23 @@ var SnippetOption = Widget.extend({ .addClass('active'); } + // --- SELECT DATA ATTRIBUTE --- + + this.$el.find('[data-select-data-attribute]') + .removeClass('active') + .filter((i, el) => { + const ancestorEl = el.closest('[data-attribute-name]'); + if (!ancestorEl) { + return false; + } + const dataName = ancestorEl.dataset.attributeName; + const defaultValue = ancestorEl.dataset.attributeDefaultValue; + const dataValue = el.dataset.selectDataAttribute; + const targetValue = this.$target[0].dataset[dataName] || defaultValue; + return (!targetValue && !dataValue) || targetValue === dataValue; + }) + .addClass('active'); + // --- SET DATA ATTRIBUTE --- (note: important to be done last because of active removal) this.el.querySelectorAll('[data-set-data-attribute]').forEach(el => { diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index d159c10ee16..2d5747e57e3 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -9,8 +9,28 @@ var options = require('web_editor.snippets.options'); var _t = core._t; var qweb = core.qweb; +// TODO should we refresh public widgets for all option changes by default ? options.Class.include({ + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + setDataAttribute: function (previewMode, dataName, $opt) { + this._super(...arguments); + this._refreshPublicWidgets(); + }, + /** + * @override + */ + selectDataAttribute: function (previewMode, value, $opt) { + this._super(...arguments); + this._refreshPublicWidgets(); + }, + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -690,33 +710,6 @@ options.registry.parallax = options.Class.extend({ onMove: function () { this._refreshPublicWidgets(); }, - - //-------------------------------------------------------------------------- - // Options - //-------------------------------------------------------------------------- - - /** - * Changes the scrolling speed of the parallax effect. - * - * @see this.selectClass for parameters - */ - scroll: function (previewMode, value) { - this.$target.attr('data-scroll-background-ratio', value); - this._refreshPublicWidgets(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - */ - _setActive: function () { - this._super.apply(this, arguments); - this.$el.find('[data-scroll]').removeClass('active') - .filter('[data-scroll="' + (this.$target.attr('data-scroll-background-ratio') || 0) + '"]').addClass('active'); - }, }); var FacebookPageDialog = weWidgets.Dialog.extend({ diff --git a/addons/website/views/snippets.xml b/addons/website/views/snippets.xml index 79fbc30e51b..c8c89c0bebe 100644 --- a/addons/website/views/snippets.xml +++ b/addons/website/views/snippets.xml @@ -1244,15 +1244,15 @@
- - No-scroll + + No-scroll - Fixed + Fixed - Very Slow - Slow - Fast - Very Fast + Very Slow + Slow + Fast + Very Fast
diff --git a/addons/website_blog/static/src/js/s_latest_posts_editor.js b/addons/website_blog/static/src/js/s_latest_posts_editor.js index 0c6ae3fc948..6000b2d27a7 100644 --- a/addons/website_blog/static/src/js/s_latest_posts_editor.js +++ b/addons/website_blog/static/src/js/s_latest_posts_editor.js @@ -1,46 +1,9 @@ odoo.define('website_blog.s_latest_posts_editor', function (require) { 'use strict'; -var core = require('web.core'); var sOptions = require('web_editor.snippets.options'); var wUtils = require('website.utils'); -var _t = core._t; - -sOptions.registry.js_get_posts_limit = sOptions.Class.extend({ - - //-------------------------------------------------------------------------- - // Options - //-------------------------------------------------------------------------- - - /** - * @see this.selectClass for parameters - */ - postsLimit: function (previewMode, value, $opt) { - value = parseInt(value); - this.$target.attr('data-posts-limit', value).data('postsLimit', value); - this.trigger_up('widgets_start_request', { - editableMode: true, - $target: this.$target, - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - */ - _setActive: function () { - this._super.apply(this, arguments); - var activeLimit = this.$target.data('postsLimit') || 3; - - this.$el.find('[data-posts-limit]').removeClass('active'); - this.$el.find('[data-posts-limit=' + activeLimit + ']').addClass('active'); - }, -}); - sOptions.registry.js_get_posts_selectBlog = sOptions.Class.extend({ /** * @override diff --git a/addons/website_blog/views/snippets.xml b/addons/website_blog/views/snippets.xml index 3e9c8e0bfbe..388bf6e0fbb 100644 --- a/addons/website_blog/views/snippets.xml +++ b/addons/website_blog/views/snippets.xml @@ -17,18 +17,18 @@ -
- - 1 post max - 2 posts max - 3 posts max - 4 posts max - 5 posts max - 6 posts max - 7 posts max - 8 posts max - 9 posts max - 10 posts max +
+ + 1 post max + 2 posts max + 3 posts max + 4 posts max + 5 posts max + 6 posts max + 7 posts max + 8 posts max + 9 posts max + 10 posts max
From 75ab52af9c067b4cbc5a67083e8d02736e11bc3c Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Mon, 18 Nov 2019 13:22:59 +0000 Subject: [PATCH 2/8] [REF] web_editor, *: make options in charge of their rendering * website, website_sale Also make the "isTopOption" method a property. This is done in preparation of better code for coupling sub-widgets used to retrieve user value (button, input, colorpicker, ...) to actual change in the edited DOM through snippet options methods. Part of https://github.com/odoo/odoo/pull/40282 In preparation of task-2122935 and other tasks --- .../static/src/js/editor/snippets.editor.js | 74 +--- .../static/src/js/editor/snippets.options.js | 375 ++++++++++-------- addons/web_editor/static/src/xml/snippets.xml | 3 - .../static/src/js/editor/snippets.options.js | 15 +- .../static/src/js/website_sale.editor.js | 2 +- 5 files changed, 230 insertions(+), 239 deletions(-) diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index 6fb1f5ebee8..bf4dd804d1d 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -321,62 +321,6 @@ var SnippetEditor = Widget.extend({ // Private //-------------------------------------------------------------------------- - /** - * Transforms option UI description into actual DOM. - * - * @private - * @param {jQuery} $el - */ - _createOptionUI: function ($el) { - const $optionSection = $(core.qweb.render('web_editor.customize_block_option')); - $optionSection.append($el); - const uiEl = $optionSection[0]; - - // Build group first as their internal components will be built after - uiEl.querySelectorAll('we-row').forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const groupEl = options.Class.prototype.buildRowElement(infos.title, infos.options); - el.parentNode.insertBefore(groupEl, el); - el.parentNode.removeChild(el); - }); - - // Build standard components - uiEl.querySelectorAll('we-select').forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const selectEl = options.Class.prototype.buildSelectElement(infos.title, infos.options); - el.parentNode.insertBefore(selectEl, el); - el.parentNode.removeChild(el); - }); - uiEl.querySelectorAll('we-checkbox').forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const checkboxEl = options.Class.prototype.buildCheckboxElement(infos.title, infos.options); - el.parentNode.insertBefore(checkboxEl, el); - el.parentNode.removeChild(el); - }); - uiEl.querySelectorAll('we-input').forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const inputEl = options.Class.prototype.buildInputElement(infos.title, infos.options); - el.parentNode.insertBefore(inputEl, el); - el.parentNode.removeChild(el); - }); - - return $optionSection; - }, - /** - * @private - * @param {HTMLElement} el - * @returns {Object} - */ - _extraInfoFromDescriptionElement: function (el) { - return { - title: el.getAttribute('string'), - options: { - classes: el.classList, - dataAttributes: el.dataset, - childNodes: [...el.childNodes], - }, - }; - }, /** * DOMElements have a default name which appears in the overlay when they * are being edited. This method retrieves this name; it can be defined @@ -418,7 +362,8 @@ var SnippetEditor = Widget.extend({ var $optionsSection = $(core.qweb.render('web_editor.customize_block_options_section', { name: this._getName(), })).data('editor', this); - $optionsSection.find('we-button-group').contents().each((i, node) => { + const $optionsSectionBtnGroup = $optionsSection.find('we-button-group'); + $optionsSectionBtnGroup.contents().each((i, node) => { if (node.nodeType === Node.TEXT_NODE) { node.parentNode.removeChild(node); } @@ -447,13 +392,15 @@ var SnippetEditor = Widget.extend({ } var optionName = val.option; - - var $ui = val.$el.children().clone(true); 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({snippetName: this._getName()}, val.data), + _.extend({ + optionName: optionName, + snippetName: this._getName(), + }, val.data), this.options ); var key = optionName || _.uniqueId('option'); @@ -465,8 +412,7 @@ var SnippetEditor = Widget.extend({ } this.styles[key] = option; option.__order = i++; - const $option = this._createOptionUI($ui).addClass('snippet-option-' + optionName); - return option.attachTo($option); + return option.appendTo(document.createDocumentFragment()); }); this.isTargetMovable = (this.selectorSiblings.length > 0 || this.selectorChildren.length > 0); @@ -476,8 +422,8 @@ var SnippetEditor = Widget.extend({ return Promise.all(defs).then(() => { const options = _.sortBy(this.styles, '__order'); options.forEach(option => { - if (option.isTopOption()) { - $optionsSection.find('we-button-group').first().prepend(option.$el); + if (option.isTopOption) { + $optionsSectionBtnGroup.prepend(option.$el); } else { $optionsSection.append(option.$el); } diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 509fa04089f..794a733ceef 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -59,6 +59,7 @@ function _computePxByRem(toRem) { * referenced thanks to the data-js key in the web_editor options template. */ var SnippetOption = Widget.extend({ + tagName: 'we-customizeblock-option', events: { 'mouseenter we-button': '_onOptionPreview', 'click we-button': '_onOptionSelection', @@ -69,6 +70,13 @@ var SnippetOption = Widget.extend({ 'click we-input': '_onOptionInputClick', 'keydown we-input input': '_onOptionInputKeydown', }, + /** + * 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, /** * The option `$el` is supposed to be the associated DOM UI element. @@ -78,15 +86,63 @@ var SnippetOption = Widget.extend({ * * @constructor */ - init: function (parent, $target, $overlay, data, options) { + init: function (parent, $uiElements, $target, $overlay, data, options) { this._super.apply(this, arguments); + + this.uiFragment = document.createDocumentFragment(); + $uiElements.clone(true).appendTo(this.uiFragment); + 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.__methodNames = []; }, + /** + * @override + */ + willStart: function () { + // Build group first as their internal components will be built after + this.uiFragment.querySelectorAll('we-row').forEach(el => { + const infos = this._extraInfoFromDescriptionElement(el); + const groupEl = this._buildRowElement(infos.title, infos.options); + el.parentNode.insertBefore(groupEl, el); + el.parentNode.removeChild(el); + }); + + // Build standard components + this.uiFragment.querySelectorAll('we-select').forEach(el => { + const infos = this._extraInfoFromDescriptionElement(el); + const selectEl = this._buildSelectElement(infos.title, infos.options); + el.parentNode.insertBefore(selectEl, el); + el.parentNode.removeChild(el); + }); + this.uiFragment.querySelectorAll('we-checkbox').forEach(el => { + const infos = this._extraInfoFromDescriptionElement(el); + const checkboxEl = this._buildCheckboxElement(infos.title, infos.options); + el.parentNode.insertBefore(checkboxEl, el); + el.parentNode.removeChild(el); + }); + this.uiFragment.querySelectorAll('we-input').forEach(el => { + const infos = this._extraInfoFromDescriptionElement(el); + const inputEl = this._buildInputElement(infos.title, infos.options); + el.parentNode.insertBefore(inputEl, el); + el.parentNode.removeChild(el); + }); + + return this._super(...arguments); + }, + /** + * @override + */ + renderElement: function () { + this._super(...arguments); + this.el.appendChild(this.uiFragment); + }, /** * Called when the option is initialized (i.e. the parent edition overlay is * shown for the first time). @@ -97,15 +153,6 @@ var SnippetOption = Widget.extend({ this._updateUI(); return this._super.apply(this, arguments); }, - /** - * Indicates if the option should be displayed in the button group at the - * top of the options panel, next to the clone/remove button. - * - * @returns {boolean} - */ - isTopOption: function () { - return false; - }, /** * Called when the parent edition overlay is covering the associated snippet * (the first time, this follows the call to the @see start method). @@ -382,6 +429,148 @@ var SnippetOption = Widget.extend({ // Private //-------------------------------------------------------------------------- + /** + * Build the correct DOM for an element according to the given options. + * + * @private + * @param {string} tagName + * @param {string} [title] + * @param {Object} [options] + * @param {string[]} [options.classes] + * @param {Object} [options.dataAttributes] + * @returns {HTMLElement} + */ + _buildElement: function (tagName, title, options) { + const el = document.createElement(tagName); + + if (title) { + const titleEl = this._buildTitleElement(title); + el.appendChild(titleEl); + } + + if (options && options.classes) { + el.classList.add(...options.classes); + } + if (options && options.dataAttributes) { + for (const key in options.dataAttributes) { + el.dataset[key] = options.dataAttributes[key]; + } + } + + return el; + }, + /** + * Build the correct DOM for a we-checkbox element. + * + * @private + * @param {string} [title] + * @param {Object} [options] - @see this._buildElement + * @returns {HTMLElement} + */ + _buildCheckboxElement: function (title, options) { + const buttonEl = this._buildElement('we-button', title, options); + buttonEl.classList.add('o_we_checkbox_wrapper'); + + const checkboxEl = document.createElement('we-checkbox'); + buttonEl.appendChild(checkboxEl); + + return buttonEl; + }, + /** + * Build the correct DOM for a we-row element. + * + * @private + * @param {string} [title] + * @param {Object} [options] - @see this._buildElement + * @param {HTMLElement[]} [options.childNodes] + * @returns {HTMLElement} + */ + _buildRowElement: function (title, options) { + const groupEl = this._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-input element. + * + * @private + * @param {string} [title] + * @param {Object} [options] - @see this._buildElement + */ + _buildInputElement: function (title, options) { + const inputWrapperEl = this._buildElement('we-input', title, options); + + var unit = inputWrapperEl.dataset.unit; + if (unit === undefined) { + unit = 'px'; + } + inputWrapperEl.dataset.unit = unit; + if (inputWrapperEl.dataset.saveUnit === undefined) { + inputWrapperEl.dataset.saveUnit = unit; + } + + var defaultValue = inputWrapperEl.dataset.defaultValue; + if (defaultValue === undefined) { + defaultValue = ('0' + unit); + } + inputWrapperEl.dataset.defaultValue = defaultValue; + + var inputEl = document.createElement('input'); + inputEl.setAttribute('type', 'text'); + inputEl.setAttribute('placeholder', defaultValue.replace(unit, '')); + inputWrapperEl.appendChild(inputEl); + + var unitEl = document.createElement('span'); + unitEl.textContent = unit; + inputWrapperEl.appendChild(unitEl); + + return inputWrapperEl; + }, + /** + * Build the correct DOM for a we-select element. + * + * @private + * @param {string} [title] + * @param {Object} [options] - @see this._buildElement + * @param {HTMLElement[]} [options.childNodes] + * @param {HTMLElement} [options.valueEl] + * @returns {HTMLElement} + */ + _buildSelectElement: function (title, options) { + const selectEl = this._buildElement('we-select', title, options); + + if (options && options.valueEl) { + selectEl.appendChild(options.valueEl); + } + + const menuTogglerEl = document.createElement('we-toggler'); + selectEl.appendChild(menuTogglerEl); + + var menuEl = document.createElement('we-select-menu'); + if (options && options.childNodes) { + options.childNodes.forEach(node => menuEl.appendChild(node)); + } + selectEl.appendChild(menuEl); + + return selectEl; + }, + /** + * @private + * @param {string} title + * @returns {HTMLElement} + */ + _buildTitleElement: function (title) { + const titleEl = document.createElement('we-title'); + titleEl.textContent = title; + return titleEl; + }, /** * Converts the given (value + unit) string to a numeric value expressed in * the given css unit. @@ -424,6 +613,21 @@ var SnippetOption = Widget.extend({ } return value * converter(cssProp, this.$target); }, + /** + * @statis + * @param {HTMLElement} el + * @returns {Object} + */ + _extraInfoFromDescriptionElement: function (el) { + return { + title: el.getAttribute('string'), + options: { + classes: el.classList, + dataAttributes: el.dataset, + childNodes: [...el.childNodes], + }, + }; + }, /** * Reactivate the options that were activated before previews. */ @@ -778,153 +982,6 @@ var SnippetOption = Widget.extend({ } this._reset(); }, - - //-------------------------------------------------------------------------- - // Static - //-------------------------------------------------------------------------- - - /** - * Build the correct DOM for an element according to the given options. - * - * @static - * @param {string} tagName - * @param {string} [title] - * @param {Object} [options] - * @param {string[]} [options.classes] - * @param {Object} [options.dataAttributes] - * @returns {HTMLElement} - */ - buildElement: function (tagName, title, options) { - const el = document.createElement(tagName); - - if (title) { - const titleEl = SnippetOption.prototype.buildTitleElement(title); - el.appendChild(titleEl); - } - - if (options && options.classes) { - el.classList.add(...options.classes); - } - if (options && options.dataAttributes) { - for (const key in options.dataAttributes) { - el.dataset[key] = options.dataAttributes[key]; - } - } - - return el; - }, - /** - * Build the correct DOM for a we-checkbox element. - * - * @static - * @param {string} [title] - * @param {Object} [options] - @see this.buildElement - * @returns {HTMLElement} - */ - buildCheckboxElement: function (title, options) { - const buttonEl = SnippetOption.prototype.buildElement('we-button', title, options); - buttonEl.classList.add('o_we_checkbox_wrapper'); - - const checkboxEl = document.createElement('we-checkbox'); - buttonEl.appendChild(checkboxEl); - - return buttonEl; - }, - /** - * Build the correct DOM for a we-row element. - * - * @static - * @param {string} [title] - * @param {Object} [options] - @see this.buildElement - * @param {HTMLElement[]} [options.childNodes] - * @returns {HTMLElement} - */ - buildRowElement: function (title, options) { - const groupEl = SnippetOption.prototype.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-input element. - * - * @static - * @param {string} [title] - * @param {Object} [options] - @see this.buildElement - */ - buildInputElement: function (title, options) { - const inputWrapperEl = SnippetOption.prototype.buildElement('we-input', title, options); - - var unit = inputWrapperEl.dataset.unit; - if (unit === undefined) { - unit = 'px'; - } - inputWrapperEl.dataset.unit = unit; - if (inputWrapperEl.dataset.saveUnit === undefined) { - inputWrapperEl.dataset.saveUnit = unit; - } - - var defaultValue = inputWrapperEl.dataset.defaultValue; - if (defaultValue === undefined) { - defaultValue = ('0' + unit); - } - inputWrapperEl.dataset.defaultValue = defaultValue; - - var inputEl = document.createElement('input'); - inputEl.setAttribute('type', 'text'); - inputEl.setAttribute('placeholder', defaultValue.replace(unit, '')); - inputWrapperEl.appendChild(inputEl); - - var unitEl = document.createElement('span'); - unitEl.textContent = unit; - inputWrapperEl.appendChild(unitEl); - - return inputWrapperEl; - }, - /** - * Build the correct DOM for a we-select element. - * - * @static - * @param {string} [title] - * @param {Object} [options] - @see this.buildElement - * @param {HTMLElement[]} [options.childNodes] - * @param {HTMLElement} [options.valueEl] - * @returns {HTMLElement} - */ - buildSelectElement: function (title, options) { - const selectEl = SnippetOption.prototype.buildElement('we-select', title, options); - - if (options && options.valueEl) { - selectEl.appendChild(options.valueEl); - } - - const menuTogglerEl = document.createElement('we-toggler'); - selectEl.appendChild(menuTogglerEl); - - var menuEl = document.createElement('we-select-menu'); - if (options && options.childNodes) { - options.childNodes.forEach(node => menuEl.appendChild(node)); - } - selectEl.appendChild(menuEl); - - return selectEl; - }, - /** - * @static - * @param {string} title - * @returns {HTMLElement} - */ - buildTitleElement: function (title) { - const titleEl = document.createElement('we-title'); - titleEl.textContent = title; - return titleEl; - }, }); //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: @@ -1219,7 +1276,7 @@ registry.colorpicker = SnippetOption.extend({ // Build the select element with a custom span to hold the color preview this.colorPreviewEl = document.createElement('span'); - const selectEl = this.buildSelectElement(this.data.string, { + const selectEl = this._buildSelectElement(this.data.string, { classes: ['o_we_so_color_palette'], childNodes: [this.colorPalette.el], valueEl: this.colorPreviewEl, @@ -1343,7 +1400,7 @@ registry.background = SnippetOption.extend({ this.removeBgEl.dataset.background = ''; this.removeBgEl.dataset.noPreview = 'true'; - this.$el.append(this.buildRowElement(this.data.string, { + this.$el.append(this._buildRowElement(this.data.string, { childNodes: [editBgEl, this.removeBgEl], })); diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml index 658bef34353..2b0ba9f4589 100644 --- a/addons/web_editor/static/src/xml/snippets.xml +++ b/addons/web_editor/static/src/xml/snippets.xml @@ -31,9 +31,6 @@ - - -
diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 2d5747e57e3..6edbc840aa4 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -329,6 +329,8 @@ options.registry.Carousel = options.Class.extend({ }); options.registry.CarouselItem = options.Class.extend({ + isTopOption: true, + /** * @override */ @@ -353,12 +355,6 @@ options.registry.CarouselItem = options.Class.extend({ this._super(...arguments); this.$carousel.off('.carousel_item_option'); }, - /** - * @override - */ - isTopOption: function () { - return true; - }, /** * Updates the slide counter. * @@ -1597,6 +1593,7 @@ options.registry.topMenuColor = options.registry.colorpicker.extend({ */ options.registry.anchor = options.Class.extend({ xmlDependencies: ['/website/static/src/xml/website.editor.xml'], + isTopOption: true, //-------------------------------------------------------------------------- // Public @@ -1620,12 +1617,6 @@ options.registry.anchor = options.Class.extend({ return this._super.apply(this, arguments); }, - /** - * @override - */ - isTopOption: function () { - return true; - }, /** * @override */ diff --git a/addons/website_sale/static/src/js/website_sale.editor.js b/addons/website_sale/static/src/js/website_sale.editor.js index 8d394bdd0b6..4f35b1bf00e 100644 --- a/addons/website_sale/static/src/js/website_sale.editor.js +++ b/addons/website_sale/static/src/js/website_sale.editor.js @@ -184,7 +184,7 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ }).then(data => { var $menu = this.$el.find('[name="style"]'); for (var k in data) { - $menu.append(this.buildCheckboxElement(data[k]['name'], { + $menu.append(this._buildCheckboxElement(data[k]['name'], { dataAttributes: { 'style': data[k]['id'], 'toggleClass': data[k]['html_class'], From b38253f400596dc6c96c2aface43684a56b24d26 Mon Sep 17 00:00:00 2001 From: qsm-odoo Date: Mon, 18 Nov 2019 16:56:42 +0000 Subject: [PATCH 3/8] [REF] web_editor, *: use widgets for left panel components Before this commit, the rendering of inner content of the left panel options was handled through _buildXXXElement methods in the options widgets. Now they are handled through sub-widgets which also handle the user events. Each sub-widget is notified with the string value it should hold according to the snippet option target and methods. Each sub-widget notifies a string value following user interactions, which snippet options can use to adapt their target through their option methods. The current sub-widgets introduced by this commit are (others will follow): 1) we-button 2) we-checkbox 3) we-input 4) we-select (typically containing multiple we-button widgets) 5) we-multi (typically containing multiple we-input widget) Following this commit only 3 standard option methods remain, which can be used with any meaningful widget: 1) selectClass -> Same as before + handle the old 'toggleClass' method when used with a checkbox-like widget. 2) selectStyle -> Same as old setStyle, introduced recently (simply renamed) 3) selectDataAttribute -> Handle both old selectDataAttribute and setDataAttribute, introduced recently The parameters these methods received are now: - previewMode: same as before - widgetValue: the string value the widget currently holds - params: additional parameters (mainly dataset of the related xml) + params.possibleValues (all the possible values for the method, meaninful for a select or a checkbox) + params.defaultValue (the value that the widget would give if it even holds no value) When defining a method in XML, the given value is the default value. e.g. data-select-style="34px" data-css-property="margin-top" -> if set on any widget, allows to retrieve a value from the user to customize the margin-top, the default value being 34px. -> if used with a we-input, allow to retrieve any value and an empty value will be equal to 34px -> if used on a checkbox, allow to toggle between no value and 34px. -> ... Hopefully, detailed documentation about all of this will follow. Code documentation should already be helpful. Last note: the _updateUI method (replacing the old _setActive) is now async (so does all the functions calling it). In particular, the _select method is now async. Part of https://github.com/odoo/odoo/pull/40282 In preparation of task-2122935 and other tasks --- .../web_editor/static/src/js/common/utils.js | 144 ++ .../static/src/js/editor/snippets.editor.js | 48 +- .../static/src/js/editor/snippets.options.js | 2021 ++++++++++------- .../static/src/scss/wysiwyg_snippets.scss | 8 + addons/web_editor/views/editor.xml | 1 + .../src/js/content/snippets.animation.js | 2 +- .../static/src/js/editor/snippets.options.js | 322 ++- addons/website/views/snippets.xml | 18 +- .../static/src/js/s_latest_posts_editor.js | 60 +- .../static/src/js/website_blog.editor.js | 4 +- addons/website_blog/views/snippets.xml | 4 +- .../static/src/js/website_form_editor.js | 31 +- .../static/tests/tours/website_form_editor.js | 2 +- addons/website_form/views/snippets.xml | 4 +- .../static/src/js/website_sale.editor.js | 98 +- addons/website_sale/views/snippets.xml | 2 +- 16 files changed, 1673 insertions(+), 1096 deletions(-) create mode 100644 addons/web_editor/static/src/js/common/utils.js diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js new file mode 100644 index 00000000000..f3373c7bad2 --- /dev/null +++ b/addons/web_editor/static/src/js/common/utils.js @@ -0,0 +1,144 @@ +odoo.define('web_editor.utils', function (require) { +'use strict'; + +/** + * window.getComputedStyle cannot work properly with CSS shortcuts (like + * 'border-width' which is a shortcut for the top + right + bottom + left border + * widths. If an option wants to customize such a shortcut, it should be listed + * here with the non-shortcuts property it stands for, in order. + * + * @type {Object} + */ +const CSS_SHORTHANDS = { + 'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'], + 'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'], +}; +/** + * Key-value mapping to list converters from an unit A to an unit B. + * - The key is a string in the format '$1-$2' where $1 is the CSS symbol of + * unit A and $2 is the CSS symbol of unit B. + * - The value is a function that converts the received value (expressed in + * unit A) to another value expressed in unit B. Two other parameters is + * received: the css property on which the unit applies and the jQuery element + * on which that css property may change. + */ +const CSS_UNITS_CONVERSION = { + 's-ms': () => 1000, + 'ms-s': () => 0.001, + 'rem-px': () => _computePxByRem(), + 'px-rem': () => _computePxByRem(true), +}; + +/** + * Computes the number of "px" needed to make a "rem" unit. Subsequent calls + * returns the cached computed value. + * + * @param {boolean} [toRem=false] + * @returns {float} - number of px by rem if 'toRem' is false + * - the inverse otherwise + */ +function _computePxByRem(toRem) { + if (_computePxByRem.PX_BY_REM === undefined) { + const htmlStyle = window.getComputedStyle(document.documentElement); + _computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']); + } + return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM; +} +/** + * Converts the given (value + unit) string to a numeric value expressed in + * the other given css unit. + * + * e.g. fct('400ms', 's') -> 0.4 + * + * @param {string} value + * @param {string} unitTo + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {number} + */ +function _convertValueToUnit(value, unitTo, cssProp, $target) { + const m = _getNumericAndUnit(value); + if (!m) { + return NaN; + } + const numValue = parseFloat(m[0]); + const valueUnit = m[1]; + return _convertNumericToUnit(numValue, valueUnit, unitTo, cssProp, $target); +} +/** + * Converts the given numeric value expressed in the given css unit into + * the corresponding numeric value expressed in the other given css unit. + * + * e.g. fct(400, 'ms', 's') -> 0.4 + * + * @param {number} value + * @param {string} unitFrom + * @param {string} unitTo + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {number} + */ +function _convertNumericToUnit(value, unitFrom, unitTo, cssProp, $target) { + if (Math.abs(value) < Number.EPSILON || unitFrom === unitTo) { + return value; + } + const converter = CSS_UNITS_CONVERSION[`${unitFrom}-${unitTo}`]; + if (converter === undefined) { + throw new Error(`Cannot convert '${unitFrom}' units into '${unitTo}' units !`); + } + return value * converter(cssProp, $target); +} +/** + * Returns the numeric value and unit of a css value. + * + * e.g. fct('400ms') -> [400, 'ms'] + * + * @param {string} value + * @returns {Array|null} + */ +function _getNumericAndUnit(value) { + const m = value.trim().match(/^([0-9.]+)(\w*)$/); + if (!m) { + return null; + } + return [m[1], m[2]]; +} +/** + * Checks if two css values are equal. + * + * @param {string} value1 + * @param {string} value2 + * @param {string} [cssProp] - the css property on which the unit applies + * @param {jQuery} [$target] - the jQuery element on which that css property + * may change + * @returns {boolean} + */ +function _areCssValuesEqual(value1, value2, cssProp, $target) { + // String comparison first + if (value1 === value2) { + return true; + } + + // Convert the second value in the unit of the first one and compare + // floating values + const data = _getNumericAndUnit(value1); + if (!data) { + return false; + } + const numValue1 = data[0]; + const numValue2 = _convertValueToUnit(value2, data[1], cssProp, $target); + return (Math.abs(numValue1 - numValue2) < Number.EPSILON); +} + +return { + CSS_SHORTHANDS: CSS_SHORTHANDS, + CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION, + computePxByRem: _computePxByRem, + convertValueToUnit: _convertValueToUnit, + convertNumericToUnit: _convertNumericToUnit, + getNumericAndUnit: _getNumericAndUnit, + areCssValuesEqual: _areCssValuesEqual, +}; +}); diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index bf4dd804d1d..693eaf4001b 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -144,6 +144,15 @@ var SnippetEditor = Widget.extend({ // 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. @@ -165,6 +174,17 @@ var SnippetEditor = Widget.extend({ option.cleanForSave(); }); }, + /** + * Closes all widgets of all options. + */ + closeWidgets: function () { + if (!this.areOptionsShown()) { + return; + } + Object.keys(this.styles).forEach(key => { + this.styles[key].closeWidgets(); + }); + }, /** * Makes the editor overlay cover the associated snippet. */ @@ -288,9 +308,7 @@ var SnippetEditor = Widget.extend({ return; } - var lastIndex = this._customize$Elements.length - 1; - var optionsAlreadyShown = !!this._customize$Elements[lastIndex].parent().length; - if (optionsAlreadyShown === show) { + if (this.areOptionsShown() === show) { return; } this.trigger_up('update_customize_elements', { @@ -666,7 +684,6 @@ var SnippetsMenu = Widget.extend({ id: 'oe_snippets', cacheSnippetTemplate: {}, events: { - 'click we-select': '_onOptionTogglerClick', 'click .o_install_btn': '_onInstallBtnClick', }, custom_events: { @@ -684,6 +701,7 @@ var SnippetsMenu = Widget.extend({ 'hide_overlay': '_onHideOverlay', 'block_preview_overlays': '_onBlockPreviewOverlays', 'unblock_preview_overlays': '_onUnblockPreviewOverlays', + 'user_value_widget_opening': '_onUserValueWidgetOpening', }, /** @@ -1668,21 +1686,6 @@ var SnippetsMenu = Widget.extend({ _onCleanForSaveDemand: function (ev) { this.cleanForSave(); }, - /** - * @private - * @param {*} ev - */ - _onOptionTogglerClick: function (ev) { - var togglerEl = ev.currentTarget; - if (togglerEl.tagName !== 'WE-TOGGLER') { - togglerEl = togglerEl.querySelector('we-toggler'); - } - - var $hierarchyTogglers = $(togglerEl).parents('we-select').children('we-toggler'); - this.$('we-select').children('we-toggler').not($hierarchyTogglers).removeClass('active'); - $hierarchyTogglers.not(togglerEl).addClass('active'); - togglerEl.classList.toggle('active'); - }, /** * Called when the overlay dimensions/positions should be recomputed. * @@ -1836,6 +1839,13 @@ var SnippetsMenu = Widget.extend({ this.customizePanel.classList.toggle('d-none', !customize); this.$('.o_we_customize_snippet_btn').toggleClass('active', customize); }, + /** + * Called when an user value widget is being opened -> close all the other + * user value widgets of all editors. + */ + _onUserValueWidgetOpening: function () { + this.snippetEditors.forEach(editor => editor.closeWidgets()); + }, }); return { diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 794a733ceef..7df4ff69ade 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -2,73 +2,851 @@ odoo.define('web_editor.snippets.options', function (require) { 'use strict'; var core = require('web.core'); -var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget; +const concurrency = require('web.concurrency'); var Widget = require('web.Widget'); +var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget; +const weUtils = require('web_editor.utils'); var weWidgets = require('wysiwyg.widgets'); var qweb = core.qweb; var _t = core._t; /** - * window.getComputedStyle cannot work properly with CSS shortcuts (like - * 'border-width' which is a shortcut for the top + right + bottom + left border - * widths. If an options wants to customize such a shortcut, it should be listed - * here with the non-shortcuts property it stands for, in order. - * - * @type {Object} + * @param {HTMLElement} el + * @param {string} [title] + * @param {Object} [options] + * @param {string[]} [options.classes] + * @param {Object} [options.dataAttributes] + * @returns {HTMLElement} - the original 'el' argument */ -const CSS_SHORTHANDS = { - 'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'], - 'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'], -}; +function _addTitleAndAllowedAttributes(el, title, options) { + if (title) { + const titleEl = _buildTitleElement(title); + el.appendChild(titleEl); + } + + if (options && options.classes) { + el.classList.add(...options.classes); + } + if (options && options.dataAttributes) { + for (const key in options.dataAttributes) { + el.dataset[key] = options.dataAttributes[key]; + } + } + + return el; +} /** - * Key-value mapping to list converters from an unit A to an unit B. - * - The key is a string in the format '$1-$2' where $1 is the CSS symbol of - * unit A and $2 is the CSS symbol of unit B. - * - The value is a function that converts the received value (expressed in - * unit A) to another value expressed in unit B. Two other parameters is - * received: the css property on which the unit applies and the jQuery element - * on which that css property may change. + * @param {string} tagName + * @param {string} title - @see _addTitleAndAllowedAttributes + * @param {Object} options - @see _addTitleAndAllowedAttributes + * @returns {HTMLElement} */ -const CSS_UNITS_CONVERSION = { - 's-ms': () => 1000, - 'ms-s': () => 0.001, - 'rem-px': () => _computePxByRem(), - 'px-rem': () => _computePxByRem(true), -}; +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; +} +/** + * 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; +} + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: /** - * Computes the number of "px" needed to make a "rem" unit. Subsequent calls - * returns the cached computed value. - * - * @param {boolean} [toRem=false] - * @returns {float} - number of px by rem if 'toRem' is false - * - the inverse otherwise + * Base class for components to be used in snippet options widgets to retrieve + * user values. */ -function _computePxByRem(toRem) { - if (_computePxByRem.PX_BY_REM === undefined) { - const htmlStyle = window.getComputedStyle(document.documentElement); - _computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']); - } - return toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM; -} +const UserValueWidget = Widget.extend({ + custom_events: { + 'user_value_change': '_onUserValueNotification', + 'user_value_preview': '_onUserValueNotification', + 'user_value_reset': '_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 + */ + _makeDescriptive: function () { + const $el = this._super(...arguments); + _addTitleAndAllowedAttributes($el[0], this.title, this.options); + return $el; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Closes the widget (only meaningful for widgets that can be closed). + */ + close: function () { + this._userValueWidgets.forEach(widget => widget.close()); + }, + /** + * 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 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 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 which is + * not the default one. + * + * @returns {boolean} + */ + isActive: function () { + return this._value && this._value !== this.getDefaultValue(); + }, + /** + * 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. + * + * @returns {boolean} + */ + isPreviewed: function () { + 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 = {}; + + for (const key in this.el.dataset) { + const dataValue = this.el.dataset[key]; + + 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; + } + }, + /** + * 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] + */ + setValue: function (value, methodName) { + this._value = value; + }, + /** + * Updates the UI to match the user value the widget currently holds, only + * if the UI can currently be updated. + * + * Note: this method is only needed if @see setValue can make the widget + * hold a value which is not synchronized with its current UI (for focus + * reasons or other ones) or if the widget is not one capable of holding + * a value (but may have an UI which depends on other elements). + * + * @returns {Promise} + */ + updateUI: async function () { + if (this._canUpdateUI()) { + await this._updateUI(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Checks whether or not the UI can be updated. Base case: if an internal + * element is focused we prevent updating the UI. + * + * @private + * @param {*} previewMode + */ + _canUpdateUI: function () { + const focusEl = document.activeElement; + if (focusEl && focusEl.tagName === 'INPUT' + && (this.el === focusEl || this.el.contains(focusEl))) { + return false; + } + return true; + }, + /** + * @private + * @param {boolean} [previewMode=false] + */ + _notifyValueChange: function (previewMode) { + const data = { + widget: this, + }; + switch (previewMode) { + case undefined: + case false: { + this.trigger_up('user_value_change', data); + break; + } + case true: { + this.trigger_up('user_value_preview', data); + break; + } + default: { + this.trigger_up('user_value_reset', data); + } + } + }, + /** + * Updates the UI to match the user value the widget currently holds (this + * method is called by @see updateUI and does not perform a check to verify + * if the UI can be updated). + * + * @private + * @returns {Promise} + */ + _updateUI: async function () { + this.el.classList.remove('o_we_preview'); + const proms = this._userValueWidgets.map(widget => widget.updateUI()); + return Promise.all(proms); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Should be called when an user event on the widget indicates a value + * change. + * + * @private + * @param {Event} ev + */ + _onUserValueChange: function (ev) { + if (ev.isDefaultPrevented()) { + return; + } + if (!this.isPreviewed()) { + this._onUserValuePreview(ev); + } + ev.preventDefault(); + + this._notifyValueChange(false); + }, + /** + * Allows container widgets to add additional data if needed. + * + * @private + * @param {OdooEvent} ev + */ + _onUserValueNotification: function (ev) { + ev.data.widget = this; + }, + /** + * Should be called when an user event on the widget indicates a value + * preview. + * + * @private + * @param {Event} ev + */ + _onUserValuePreview: function (ev) { + if (ev.isDefaultPrevented()) { + return; + } + ev.preventDefault(); + + this.el.classList.add('o_we_preview'); + this._notifyValueChange(true); + }, + /** + * Should be called when an user event on the widget indicates a value + * reset. + * + * @private + * @param {Event} ev + */ + _onUserValueReset: function (ev) { + if (!this.isPreviewed()) { + return; + } + if (ev.isDefaultPrevented()) { + return; + } + ev.preventDefault(); + + this.el.classList.remove('o_we_preview'); + this._notifyValueChange('reset'); + }, +}); + +const ButtonUserValueWidget = UserValueWidget.extend({ + tagName: 'we-button', + events: { + 'click': '_onUserValueChange', + 'mouseenter': '_onUserValuePreview', + 'mouseleave': '_onUserValueReset', + }, + + /** + * @override + */ + start: function (parent, title, options) { + if (this.options && this.options.childNodes) { + this.options.childNodes.forEach(node => this.el.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 + */ + setValue: function (value, methodName) { + let active = !!value; + if (methodName) { + if (!this._methodsNames.includes(methodName)) { + return; + } + active = (this.getActiveValue(methodName) === value); + } + this.el.classList.toggle('active', active); + }, +}); + +const CheckboxUserValueWidget = ButtonUserValueWidget.extend({ + className: (ButtonUserValueWidget.prototype.className || '') + ' o_we_checkbox_wrapper', + + /** + * @override + */ + start: function () { + const checkboxEl = document.createElement('we-checkbox'); + this.el.appendChild(checkboxEl); + + return this._super(...arguments); + }, +}); + +const SelectUserValueWidget = UserValueWidget.extend({ + tagName: 'we-select', + events: { + 'click': '_onClick', + }, + + /** + * @override + */ + start: function () { + if (this.options && this.options.valueEl) { + this.el.appendChild(this.options.valueEl); + } + + this.menuTogglerEl = document.createElement('we-toggler'); + this.el.appendChild(this.menuTogglerEl); + + this.menuEl = document.createElement('we-select-menu'); + if (this.options && this.options.childNodes) { + this.options.childNodes.forEach(node => this.menuEl.appendChild(node)); + } + this.el.appendChild(this.menuEl); + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + close: function () { + this._super(...arguments); + this.menuTogglerEl.classList.remove('active'); + }, + /** + * @override + */ + getValue: function (methodName) { + let activeWidget = this._userValueWidgets.find(widget => widget.isPreviewed()); + if (!activeWidget) { + activeWidget = this._userValueWidgets.find(widget => widget.isActive()); + } + if (activeWidget) { + return activeWidget.getActiveValue(methodName); + } + return this._super(...arguments); + }, + /** + * @override + */ + isContainer: function () { + return true; + }, + /** + * @override + */ + setValue: function (value, methodName) { + this._userValueWidgets.forEach(widget => { + widget.setValue('__NULL__', methodName); + }); + for (const widget of [...this._userValueWidgets].reverse()) { + 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; + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _updateUI: async function () { + await this._super(...arguments); + const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); + this.menuTogglerEl.textContent = activeWidget ? activeWidget.el.textContent : "/"; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the select is clicked anywhere -> open/close it. + * + * @private + */ + _onClick: function () { + if (!this.menuTogglerEl.classList.contains('active')) { + this.trigger_up('user_value_widget_opening'); + } + this.menuTogglerEl.classList.toggle('active'); + }, +}); + +const InputUserValueWidget = UserValueWidget.extend({ + tagName: 'we-input', + events: { + 'input input': '_onInputInput', + 'blur input': '_onInputBlur', + + 'click': '_onInputClick', + 'keydown input': '_onInputKeydown', + }, + + /** + * @override + */ + start: function () { + const unit = this.el.dataset.unit || ''; + this.el.dataset.unit = unit; + if (this.el.dataset.saveUnit === undefined) { + this.el.dataset.saveUnit = unit; + } + + this.inputEl = document.createElement('input'); + this.inputEl.setAttribute('type', 'text'); + this.el.appendChild(this.inputEl); + + var unitEl = document.createElement('span'); + unitEl.textContent = unit; + this.el.appendChild(unitEl); + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + getValue: function (methodName) { + const widgetValue = this._super(...arguments); + + const params = this.getMethodsParams(methodName); + if (!params.unit) { + return widgetValue; + } + + let defaultVal = weUtils.convertValueToUnit(params.defaultValue, params.saveUnit, params.cssProperty, this.$target); + defaultVal = isNaN(defaultVal) ? params.defaultValue : `${parseFloat(defaultVal.toFixed(3))}${params.saveUnit}`; + + return widgetValue.split(/\s+/g).map(v => { + const numValue = parseFloat(v); + if (isNaN(numValue)) { + return defaultVal; + } else { + const value = weUtils.convertNumericToUnit(numValue, params.unit, params.saveUnit, params.cssProperty, this.$target); + return `${parseFloat(value.toFixed(3))}${params.saveUnit}`; + } + }).join(' '); + }, + /** + * @override + */ + loadMethodsData: function () { + this._super(...arguments); + let defaultVal = ''; + for (const methodName of this._methodsNames) { + const params = this.getMethodsParams(methodName); + if (params.defaultValue && params.defaultValue !== 'true') { + defaultVal = weUtils.convertValueToUnit(params.defaultValue, params.saveUnit, params.cssProperty, this.$target); + defaultVal = isNaN(defaultVal) ? params.defaultValue : `${parseFloat(defaultVal.toFixed(3))}`; + } + } + this.inputEl.setAttribute('placeholder', defaultVal); + }, + /** + * @override + */ + setValue: function (value, methodName) { + const params = this.getMethodsParams(methodName); + if (!params.unit) { + return this._super(value, methodName); + } + + const defaultValNum = weUtils.convertValueToUnit(params.defaultValue, params.unit, params.cssProperty, this.$target); + + value = value.split(' ').map(v => { + const numValue = weUtils.convertValueToUnit(v, params.unit, params.cssProperty, this.$target); + if (isNaN(numValue) || Math.abs(numValue - defaultValNum) < Number.EPSILON) { + return ''; // Either equal to the default value and we don't want to display it, or something not supported + } + return `${parseFloat(numValue.toFixed(3))}`; + }).join(' '); + + this._super(value, methodName); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _updateUI: async function () { + 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 + */ + _onInputClick: function (ev) { + const inputEl = ev.currentTarget.querySelector('input'); + if (inputEl) { + inputEl.select(); + } + }, + /** + * @private + * @param {Event} ev + */ + _onInputKeydown: function (ev) { + const input = ev.currentTarget; + let value = parseFloat(input.value || input.placeholder); + if (isNaN(value)) { + return; + } + + let step = parseFloat(input.parentNode.dataset.step); + if (isNaN(step)) { + step = 1.0; + } + switch (ev.which) { + case $.ui.keyCode.UP: { + value += step; + break; + } + case $.ui.keyCode.DOWN: { + value -= step; + break; + } + default: { + return; + } + } + + input.value = `${parseFloat(value.toFixed(3))}`; + $(input).trigger('input'); + }, +}); + +const MultiUserValueWidget = UserValueWidget.extend({ + tagName: 'we-multi', + + /** + * @override + */ + start: function () { + this.el.appendChild(_buildRowElement('', this.options)); + 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 + */ + setValue: function (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++) { + this._userValueWidgets[i].setValue(values.shift() || '', methodName); + } + this._userValueWidgets[this._userValueWidgets.length - 1].setValue(values.join(' '), methodName); + }, +}); + +const userValueWidgetsRegistry = { + 'we-button': ButtonUserValueWidget, + 'we-checkbox': CheckboxUserValueWidget, + 'we-select': SelectUserValueWidget, + 'we-input': InputUserValueWidget, + 'we-multi': MultiUserValueWidget, +}; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: /** * Handles a set of options for one snippet. The registry returned by this - * module contains the names of the specialized SnippetOption which can be + * module contains the names of the specialized SnippetOptionWidget which can be * referenced thanks to the data-js key in the web_editor options template. */ -var SnippetOption = Widget.extend({ +const SnippetOptionWidget = Widget.extend({ tagName: 'we-customizeblock-option', - events: { - 'mouseenter we-button': '_onOptionPreview', - 'click we-button': '_onOptionSelection', - 'mouseleave we-button': '_onOptionCancel', - - 'input we-input input': '_onOptionPreview', - 'blur we-input input': '_onOptionInputBlur', - 'click we-input': '_onOptionInputClick', - 'keydown we-input input': '_onOptionInputKeydown', + custom_events: { + 'user_value_change': '_onOptionSelection', + 'user_value_preview': '_onOptionPreview', + 'user_value_reset': '_onOptionCancel', }, /** * Indicates if the option should be displayed in the button group at the @@ -89,8 +867,7 @@ var SnippetOption = Widget.extend({ init: function (parent, $uiElements, $target, $overlay, data, options) { this._super.apply(this, arguments); - this.uiFragment = document.createDocumentFragment(); - $uiElements.clone(true).appendTo(this.uiFragment); + this.$originalUIElements = $uiElements; this.$target = $target; this.$overlay = $overlay; @@ -100,41 +877,18 @@ var SnippetOption = Widget.extend({ this.className = 'snippet-option-' + this.data.optionName; this.ownerDocument = this.$target[0].ownerDocument; - this.__methodNames = []; + + this._userValueWidgets = []; + this._selectEventMutex = new concurrency.Mutex(); }, /** * @override */ - willStart: function () { - // Build group first as their internal components will be built after - this.uiFragment.querySelectorAll('we-row').forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const groupEl = this._buildRowElement(infos.title, infos.options); - el.parentNode.insertBefore(groupEl, el); - el.parentNode.removeChild(el); + willStart: async function () { + await this._super(...arguments); + return this._renderOriginalXML().then(uiFragment => { + this.uiFragment = uiFragment; }); - - // Build standard components - this.uiFragment.querySelectorAll('we-select').forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const selectEl = this._buildSelectElement(infos.title, infos.options); - el.parentNode.insertBefore(selectEl, el); - el.parentNode.removeChild(el); - }); - this.uiFragment.querySelectorAll('we-checkbox').forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const checkboxEl = this._buildCheckboxElement(infos.title, infos.options); - el.parentNode.insertBefore(checkboxEl, el); - el.parentNode.removeChild(el); - }); - this.uiFragment.querySelectorAll('we-input').forEach(el => { - const infos = this._extraInfoFromDescriptionElement(el); - const inputEl = this._buildInputElement(infos.title, infos.options); - el.parentNode.insertBefore(inputEl, el); - el.parentNode.removeChild(el); - }); - - return this._super(...arguments); }, /** * @override @@ -142,6 +896,7 @@ var SnippetOption = Widget.extend({ renderElement: function () { this._super(...arguments); this.el.appendChild(this.uiFragment); + this.uiFragment = null; }, /** * Called when the option is initialized (i.e. the parent edition overlay is @@ -149,8 +904,8 @@ var SnippetOption = Widget.extend({ * * @override */ - start: function () { - this._updateUI(); + start: async function () { + await this._updateUI(); return this._super.apply(this, arguments); }, /** @@ -212,176 +967,107 @@ var SnippetOption = Widget.extend({ //-------------------------------------------------------------------------- /** - * Default option method which allows to handle an user input and set the - * appropriate data attribute on the associated snippet. + * 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} previewMode - never 'reset' for this method (@see this.selectClass) - * @param {Object} dataName - the data name to customize - * @param {jQuery} $opt - the related DOMElement option + * @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 */ - setDataAttribute: function (previewMode, dataName, $opt) { - let hasUserValue = false; - const weInput = $opt[0]; - const inputValue = weInput.querySelector('input').value.trim(); - const inputUnit = weInput.dataset.unit; - const saveUnit = weInput.dataset.saveUnit; - const defaultValue = weInput.dataset.defaultValue; - - let value; - if (inputValue) { - const numValue = parseFloat(inputValue); - if (!isNaN(numValue)) { - hasUserValue = true; - value = this._convertNumericToUnit(numValue, inputUnit, saveUnit); + selectClass: function (previewMode, widgetValue, params) { + for (const classNames of params.possibleValues) { + if (classNames) { + this.$target[0].classList.remove(...classNames.trim().split(/\s+/g)); } } - if (value === undefined) { - value = this._convertValueToUnit(defaultValue, saveUnit); - } - this.$target[0].dataset[dataName] = isNaN(value) ? defaultValue : parseFloat(value.toFixed(3)); - - var extraClass = weInput.dataset.extraClass; - if (extraClass) { - this.$target.toggleClass(extraClass, hasUserValue); + if (widgetValue) { + this.$target[0].classList.add(...widgetValue.trim().split(/\s+/g)); } }, /** - * Default option method which allows to handle an user input and set the - * appropriate css style on the associated snippet. + * 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 - never 'reset' for this method (@see this.selectClass) - * @param {Object} mainCssProp - the cssProp to customize - * @param {jQuery} $opt - the related DOMElement option + * @param {boolean} previewMode - @see this.selectClass + * @param {string} widgetValue + * @param {Object} params */ - setStyle: function (previewMode, mainCssProp, $opt) { - let hasUserValue = false; - const cssProps = CSS_SHORTHANDS[mainCssProp] || [mainCssProp]; - - // Join all inputs controlling the same css property and split user - // input into sub-properties (note this code handles the two at the same - // time but should normally not be combined). - const $weInputs = this.$el.find(`[data-set-style=${mainCssProp}]`); - const subValuesByInput = _.map($weInputs, weInput => { - const inputValue = weInput.querySelector('input').value.trim(); - const inputUnit = weInput.dataset.unit; - const saveUnit = weInput.dataset.saveUnit; - const defaultValue = weInput.dataset.defaultValue; - let convertedDefaultValue = this._convertValueToUnit(defaultValue, saveUnit, mainCssProp); - convertedDefaultValue = isNaN(convertedDefaultValue) ? defaultValue : (parseFloat(convertedDefaultValue.toFixed(3)) + saveUnit); - - const values = inputValue.split(/\s+/g).map(v => { - const numValue = parseFloat(v); - if (isNaN(numValue)) { - return convertedDefaultValue; - } else { - hasUserValue = true; - const value = this._convertNumericToUnit(numValue, inputUnit, saveUnit, mainCssProp); - return parseFloat(value.toFixed(3)) + saveUnit; + selectDataAttribute: function (previewMode, widgetValue, params) { + const dataName = params.attributeName; + if (dataName) { + if (params.saveUnit && !params.withUnit) { + // Values that come with an unit are saved without unit as + // data-attribute unless told otherwise. + widgetValue = widgetValue.split(params.saveUnit).join(''); + } + this.$target[0].dataset[dataName] = widgetValue; + } + if (params.extraClass) { + this.$target.toggleClass(params.extraClass, params.defaultValue !== widgetValue); + } + }, + /** + * 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 + */ + selectStyle: function (previewMode, widgetValue, params) { + const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; + const values = widgetValue.split(/\s+/g); + while (values.length < cssProps.length) { + switch (values.length) { + case 1: + case 2: { + values.push(values[0]); + break; } - }); - 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]); + case 3: { + values.push(values[1]); + break; + } + default: { + values.push(values[values.length - 1]); } } - return values; - }); + } 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); + } + const styles = window.getComputedStyle(this.$target[0]); - cssProps.forEach((cssProp, i) => { - const cssValue = subValuesByInput.map(inputSubValues => inputSubValues[i]).join(' '); - if (styles[cssProp] !== cssValue) { + 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; + } - var extraClass = $opt[0].dataset.extraClass; - if (extraClass) { - this.$target.toggleClass(extraClass, hasUserValue); - } - }, - /** - * 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 sub-collapse with each item having a `data-select-class` - * value allowing to choose the associated 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 {*} value - the class to activate ($opt.data('selectClass')) - * @param {jQuery} $opt - the related DOMElement option - */ - selectClass: function (previewMode, value, $opt) { - var $group = $opt && $opt.parents('we-select').last(); - if (!$group || !$group.length) { - $group = this.$el; - } - var $lis = $group.find('[data-select-class]'); - var classes = $lis.map(function () { - return $(this).data('selectClass'); - }).get().join(' '); - - this.$target.removeClass(classes); - if (value) { - this.$target.addClass(value); - } - }, - /** - * Default option method which allows to select one and only one value in - * the option values set and set it on the associated snippet as a data - * attribute. The name of the data attribute is given by the closest - * ancestor's data value for 'attributeName'. - * - * @param {boolean} previewMode - @see this.selectClass - * @param {Object} value - the data attribute value to set - * @param {jQuery} $opt - the related DOMElement option - */ - selectDataAttribute: function (previewMode, value, $opt) { - const $ancestor = $opt && $opt.closest('[data-attribute-name]'); - if (!$ancestor || !$ancestor.length) { - return; - } - const dataName = $ancestor[0].dataset.attributeName; - if (!dataName) { - return; - } - this.$target[0].dataset[dataName] = value || ''; - }, - /** - * Default option method which allows to select one or multiple classes in - * the option classes set and set it on the associated snippet. The common - * case is having a sub-collapse with each item having a `data-toggle-class` - * value allowing to toggle the associated class. - * - * @see this.selectClass - */ - toggleClass: function (previewMode, value, $opt) { - const $opts = this.$el.find('[data-toggle-class]').not('[data-set-style]'); - const classes = $opts.map(function () { - return $(this).data('toggleClass'); - }).get().join(' '); - const activeClasses = $opts.filter('.active, :has(.active)').map(function () { - return $(this).data('toggleClass'); - }).get().join(' '); - - this.$target.removeClass(classes).addClass(activeClasses); - if (value && previewMode !== 'reset') { - this.$target.toggleClass(value); + if (params.extraClass) { + this.$target.toggleClass(params.extraClass, hasUserValue); } }, @@ -398,6 +1084,12 @@ var SnippetOption = Widget.extend({ $: function () { return this.$target.find.apply(this.$target, arguments); }, + /** + * Closes all user value widgets. + */ + closeWidgets: function () { + this._userValueWidgets.forEach(widget => widget.close()); + }, /** * Sometimes, options may need to notify other options, even in parent * editors. This can be done thanks to the 'option_update' event, which @@ -405,10 +1097,11 @@ var SnippetOption = Widget.extend({ * * @param {string} name - an identifier for a type of update * @param {*} data + * @returns {Promise} */ - notify: function (name, data) { + notify: async function (name, data) { if (name === 'target') { - this.setTarget(data); + await this.setTarget(data); } }, /** @@ -418,10 +1111,11 @@ var SnippetOption = Widget.extend({ * function allows to set the option's target. * * @param {jQuery} $target - the new target element + * @returns {Promise} */ - setTarget: function ($target) { + setTarget: async function ($target) { this.$target = $target; - this._updateUI(); + await this._updateUI(); this.$target.trigger('snippet-option-change', [this]); }, @@ -430,191 +1124,61 @@ var SnippetOption = Widget.extend({ //-------------------------------------------------------------------------- /** - * Build the correct DOM for an element according to the given options. + * Returns the string value that should be hold by the widget which is + * related to the given method name. * * @private - * @param {string} tagName - * @param {string} [title] - * @param {Object} [options] - * @param {string[]} [options.classes] - * @param {Object} [options.dataAttributes] - * @returns {HTMLElement} + * @param {string} methodName + * @param {Object} params + * @returns {Promise|string} */ - _buildElement: function (tagName, title, options) { - const el = document.createElement(tagName); - - if (title) { - const titleEl = this._buildTitleElement(title); - el.appendChild(titleEl); - } - - if (options && options.classes) { - el.classList.add(...options.classes); - } - if (options && options.dataAttributes) { - for (const key in options.dataAttributes) { - el.dataset[key] = options.dataAttributes[key]; + _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 'selectDataAttribute': { + const dataName = params.attributeName; + let dataValue = (this.$target[0].dataset[dataName] || '').trim(); + if (params.saveUnit && !params.withUnit) { + dataValue = dataValue.split(/\s+/g).map(v => v + params.saveUnit).join(' '); + } + return dataValue || params.attributeDefaultValue || ''; + } + case 'selectStyle': { + const styles = window.getComputedStyle(this.$target[0]); + const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; + const cssValues = cssProps.map(cssProp => { + return styles[cssProp].trim(); + }); + 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(); + } + return cssValues.join(' '); } } - - return el; }, /** - * Build the correct DOM for a we-checkbox element. - * - * @private - * @param {string} [title] - * @param {Object} [options] - @see this._buildElement - * @returns {HTMLElement} - */ - _buildCheckboxElement: function (title, options) { - const buttonEl = this._buildElement('we-button', title, options); - buttonEl.classList.add('o_we_checkbox_wrapper'); - - const checkboxEl = document.createElement('we-checkbox'); - buttonEl.appendChild(checkboxEl); - - return buttonEl; - }, - /** - * Build the correct DOM for a we-row element. - * - * @private - * @param {string} [title] - * @param {Object} [options] - @see this._buildElement - * @param {HTMLElement[]} [options.childNodes] - * @returns {HTMLElement} - */ - _buildRowElement: function (title, options) { - const groupEl = this._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-input element. - * - * @private - * @param {string} [title] - * @param {Object} [options] - @see this._buildElement - */ - _buildInputElement: function (title, options) { - const inputWrapperEl = this._buildElement('we-input', title, options); - - var unit = inputWrapperEl.dataset.unit; - if (unit === undefined) { - unit = 'px'; - } - inputWrapperEl.dataset.unit = unit; - if (inputWrapperEl.dataset.saveUnit === undefined) { - inputWrapperEl.dataset.saveUnit = unit; - } - - var defaultValue = inputWrapperEl.dataset.defaultValue; - if (defaultValue === undefined) { - defaultValue = ('0' + unit); - } - inputWrapperEl.dataset.defaultValue = defaultValue; - - var inputEl = document.createElement('input'); - inputEl.setAttribute('type', 'text'); - inputEl.setAttribute('placeholder', defaultValue.replace(unit, '')); - inputWrapperEl.appendChild(inputEl); - - var unitEl = document.createElement('span'); - unitEl.textContent = unit; - inputWrapperEl.appendChild(unitEl); - - return inputWrapperEl; - }, - /** - * Build the correct DOM for a we-select element. - * - * @private - * @param {string} [title] - * @param {Object} [options] - @see this._buildElement - * @param {HTMLElement[]} [options.childNodes] - * @param {HTMLElement} [options.valueEl] - * @returns {HTMLElement} - */ - _buildSelectElement: function (title, options) { - const selectEl = this._buildElement('we-select', title, options); - - if (options && options.valueEl) { - selectEl.appendChild(options.valueEl); - } - - const menuTogglerEl = document.createElement('we-toggler'); - selectEl.appendChild(menuTogglerEl); - - var menuEl = document.createElement('we-select-menu'); - if (options && options.childNodes) { - options.childNodes.forEach(node => menuEl.appendChild(node)); - } - selectEl.appendChild(menuEl); - - return selectEl; - }, - /** - * @private - * @param {string} title - * @returns {HTMLElement} - */ - _buildTitleElement: function (title) { - const titleEl = document.createElement('we-title'); - titleEl.textContent = title; - return titleEl; - }, - /** - * Converts the given (value + unit) string to a numeric value expressed in - * the given css unit. - * - * e.g. fct('400ms', 's') -> 0.4 - * - * @param {string} value - * @param {string} unitTo - * @param {string} [cssProp] - * @returns {number} - */ - _convertValueToUnit: function (value, unitTo, cssProp) { - const m = value.trim().match(/^([0-9.]+)(\w*)$/); - if (!m) { - return NaN; - } - const numValue = parseFloat(m[1]); - const valueUnit = m[2]; - return this._convertNumericToUnit(numValue, valueUnit, unitTo, cssProp); - }, - /** - * Converts the given numeric value expressed in the given css unit into - * the corresponding numeric value expressed in the other given css unit. - * - * e.g. fct(400, 'ms', 's') -> 0.4 - * - * @param {number} value - * @param {string} unitFrom - * @param {string} unitTo - * @param {string} [cssProp] - * @returns {number} - */ - _convertNumericToUnit: function (value, unitFrom, unitTo, cssProp) { - if (Math.abs(value) < Number.EPSILON || unitFrom === unitTo) { - return value; - } - const converter = CSS_UNITS_CONVERSION[`${unitFrom}-${unitTo}`]; - if (converter === undefined) { - throw new Error(`Cannot convert ${unitFrom} into ${unitTo} !`); - } - return value * converter(cssProp, this.$target); - }, - /** - * @statis + * @static * @param {HTMLElement} el * @returns {Object} */ @@ -629,20 +1193,118 @@ var SnippetOption = Widget.extend({ }; }, /** - * Reactivate the options that were activated before previews. + * @private + * @param {string} widgetName + * @param {UserValueWidget|this|null} parent + * @param {string} title + * @param {Object} options + * @returns {UserValueWidget} */ - _reset: function () { - var self = this; - var $actives = this.$el.find('we-button.active'); - _.each($actives, function (activeElement) { - var $activeElement = $(activeElement); - self.__methodNames = _.without.apply(_, [self.__methodNames].concat(_.keys($activeElement.data()))); - self._select('reset', $activeElement); + _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); + + // Build layouting components first + uiFragment.querySelectorAll('we-row').forEach(el => { + const infos = this._extraInfoFromDescriptionElement(el); + const groupEl = _buildRowElement(infos.title, infos.options); + el.parentNode.insertBefore(groupEl, el); + el.parentNode.removeChild(el); }); - _.each(this.__methodNames, function (methodName) { - self[methodName]('reset'); + + // Load widgets + await this._renderCustomXML(uiFragment); + await this._renderXMLWidgets(uiFragment); + await this._renderCustomWidgets(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 {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(); }); - this.__methodNames = []; }, /** * Activates the option associated to the given DOM element. @@ -652,286 +1314,66 @@ var SnippetOption = Widget.extend({ * - 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 {jQuery} $opt - the related DOMElement option + * @param {UserValueWidget} widget - the widget which triggered the option change + * @returns {Promise} */ - _select: function (previewMode, $opt) { + _select: async function (previewMode, widget) { // Options can say they respond to strong choice - if (previewMode && ($opt.data('noPreview') || $opt.closest('[data-no-preview="true"]').length)) { + if (previewMode && (widget.$el.closest('[data-no-preview="true"]').length)) { + // TODO the no-preview flag should be retrieved through widget params return; } // If it is not preview mode, the user selected the option for good // (so record the action) if (!previewMode) { - this._reset(); this.trigger_up('request_history_undo_record', {$target: this.$target}); } - // Search for methods (data-...) (i.e. data-toggle-class) on the - // selected (sub)option and its parents - var el = $opt[0]; - var methods = []; - do { - methods.push([el, el.dataset]); - el = el.parentNode; - } while (this.$el.parent().has(el).length); - - // Call the found method in the right order (parents -> child) - methods.reverse().forEach(data => { - var $el = $(data[0]); - var methods = data[1]; - - const isInput = $el.is('we-input'); - - Object.keys(methods).forEach(methodName => { - if (!this[methodName]) { - return; - } - if (previewMode === true && !isInput) { - this.__methodNames.push(methodName); - } - this[methodName](previewMode, methods[methodName], $el); - }); + widget.getMethodsNames().forEach(methodName => { + this[methodName](previewMode, widget.getValue(methodName), widget.getMethodsParams(methodName)); }); - this.__methodNames = _.uniq(this.__methodNames); if (!previewMode) { - this._updateUI(); + await this._updateUI(w => !w.isPreviewed() || w === widget); + } else { + await this._updateUI(w => w !== widget && !w.isPreviewed()); } this.$target.trigger('content_changed'); }, /** - * Tweaks the option DOM elements to show the selected value according to - * the state of the $target the option customizes. + * Updates the UI. For widget update, @see _computeWidgetState. * - * @todo should be extendable in a more easy way - * @private + * @param {function} [callback] - used to filter widgets to update + * @returns {Promise} */ - _setActive: function () { - var self = this; - - // --- TOGGLE CLASS --- - - this.$el.find('[data-toggle-class]') - .removeClass('active') - .filter(function () { - var className = $(this).data('toggleClass'); - return !className || self.$target.hasClass(className); - }) - .addClass('active'); - - // --- SELECT CLASS --- - - // Get submenus which are not inside submenus - var $submenus = this.$el.find('we-select') - .not('we-select *'); - - // Add unique active class for each submenu active item - _.each($submenus, function (submenu) { - var $elements = $(submenu).find('[data-select-class]'); - _processSelectClassElements($elements); - }); - - // Add unique active class for out-of-submenu active item - var $externalElements = this.$el.find('[data-select-class]') - .not('we-select *'); - _processSelectClassElements($externalElements); - - function _processSelectClassElements($elements) { - var maxNbClasses = -1; - $elements.removeClass('active') - .filter(function () { - var className = $(this).data('selectClass'); - var nbClasses = className ? className.split(' ').length : 0; - if (nbClasses >= maxNbClasses && (!className || self.$target.hasClass(className))) { - maxNbClasses = nbClasses; - return true; - } - return false; - }) - .last() - .addClass('active'); - } - - // --- SELECT DATA ATTRIBUTE --- - - this.$el.find('[data-select-data-attribute]') - .removeClass('active') - .filter((i, el) => { - const ancestorEl = el.closest('[data-attribute-name]'); - if (!ancestorEl) { - return false; - } - const dataName = ancestorEl.dataset.attributeName; - const defaultValue = ancestorEl.dataset.attributeDefaultValue; - const dataValue = el.dataset.selectDataAttribute; - const targetValue = this.$target[0].dataset[dataName] || defaultValue; - return (!targetValue && !dataValue) || targetValue === dataValue; - }) - .addClass('active'); - - // --- SET DATA ATTRIBUTE --- (note: important to be done last because of active removal) - - this.el.querySelectorAll('[data-set-data-attribute]').forEach(el => { - el.classList.remove('active'); - const inputEl = el.querySelector('input'); - if (inputEl === document.activeElement) { + _updateUI: async function (callback) { + // 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 => { + if (callback && !callback(widget)) { return; } - const inputUnit = el.dataset.unit; - const saveUnit = el.dataset.saveUnit; - const defaultValue = el.dataset.defaultValue; - const convertedDefaultValue = this._convertValueToUnit(defaultValue, inputUnit); - - let value = this.$target[0].dataset[el.dataset.setDataAttribute]; - let numValue = parseFloat(value); - if (!isNaN(numValue)) { - numValue = this._convertNumericToUnit(numValue, saveUnit, inputUnit); - } - if (isNaN(numValue) || Math.abs(numValue - convertedDefaultValue) < Number.EPSILON) { - value = ''; - } else { - value = parseFloat(numValue.toFixed(3)); - } - inputEl.value = value; - }); - - // --- SET STYLE --- (note: important to be done last because of active removal) - - let styles; - const seenSetStyles = []; - this.el.querySelectorAll('[data-set-style]').forEach(el => { - const mainCssProp = el.dataset.setStyle; - if (seenSetStyles.includes(mainCssProp)) { - return; - } - seenSetStyles.push(mainCssProp); - - const $els = this.$el.find(`[data-set-style="${mainCssProp}"]`); - $els.removeClass('active'); - const $inputs = $els.find('> input'); - if (_.any($inputs, input => input === document.activeElement)) { - return; - } - - styles = styles || window.getComputedStyle(this.$target[0]); - const cssProps = CSS_SHORTHANDS[mainCssProp] || [mainCssProp]; - - const valuesByProp = cssProps.map(cssProp => { - const cssValue = styles[cssProp]; - if (!cssValue) { - return []; - } - - return cssValue.split(/\s+/).map((v, i) => { - const inputUnit = $els[i].dataset.unit; - const numValue = this._convertValueToUnit(v, inputUnit, mainCssProp); - return isNaN(numValue) ? v : numValue; - }); + const methodsNames = widget.getMethodsNames(); + const proms = methodsNames.map(async methodName => { + const value = await this._computeWidgetState(methodName, widget.getMethodsParams(methodName)); + widget.setValue(value, methodName); }); - _.each($els, (el, i) => { - let values = valuesByProp.map(propValues => propValues[i]); + await Promise.all(proms); - if (values.length === 4 && Math.abs(values[3] - values[1]) < Number.EPSILON) { - values.pop(); - } - if (values.length === 3 && Math.abs(values[2] - values[0]) < Number.EPSILON) { - values.pop(); - } - if (values.length === 2 && Math.abs(values[1] - values[0]) < Number.EPSILON) { - values.pop(); - } - - const inputUnit = el.dataset.unit; - const defaultValue = el.dataset.defaultValue; - const convertedDefaultValue = this._convertValueToUnit(defaultValue, inputUnit, mainCssProp); - if (values.length === 1 && Math.abs(values[0] - convertedDefaultValue) < Number.EPSILON) { - values.pop(); - } - - values = values.filter(v => isFinite(v)); - values = values.map(v => parseFloat(v.toFixed(3))); - $inputs[i].value = (values.length ? values.join(' ') : ''); - }); - }); - }, - /** - * @private - */ - _updateUI: function () { - this._setActive(); - - this.el.querySelectorAll('we-select').forEach(selectEl => { - const activeEl = selectEl.querySelector('we-button.active'); - const valueEl = selectEl.querySelector('we-toggler'); - if (valueEl) { - valueEl.textContent = activeEl ? activeEl.textContent : "/"; - } + // Refresh the UI of all widgets (after all the current values they + // hold have been updated). + return widget.updateUI(); }); + return Promise.all(proms); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- - /** - * @private - * @param {Event} ev - */ - _onOptionInputBlur: 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._onOptionSelection(ev); - }); - }, - /** - * @private - * @param {Event} ev - */ - _onOptionInputClick: function (ev) { - const inputEl = ev.currentTarget.querySelector('input'); - if (inputEl) { - inputEl.select(); - } - }, - /** - * @private - * @param {Event} ev - */ - _onOptionInputKeydown: function (ev) { - const input = ev.currentTarget; - let value = parseFloat(input.value || input.placeholder); - if (isNaN(value)) { - return; - } - - let step = parseFloat(input.parentNode.dataset.step); - if (isNaN(step)) { - step = 1.0; - } - switch (ev.which) { - case $.ui.keyCode.UP: - value += step; - break; - case $.ui.keyCode.DOWN: - value -= step; - break; - default: - return; - } - - input.value = parseFloat(value.toFixed(3)); - $(input).trigger('input'); - }, /** * Called when a option link is entered or an option input content is being * modified -> activates the related option in preview mode. @@ -940,17 +1382,12 @@ var SnippetOption = Widget.extend({ * @param {Event} ev */ _onOptionPreview: function (ev) { - var $opt = $(ev.target).closest('we-button, we-input'); - if (!$opt.length) { - return; - } - - if (!$opt.is(':hasData')) { - return; - } - this.__click = false; - this._select(true, $opt); - this.$target.trigger('snippet-option-preview', [this]); + ev.stopPropagation(); + this._selectEventMutex.exec(() => { + return this._select(true, ev.data.widget).then(() => { + this.$target.trigger('snippet-option-preview', [this]); + }); + }); }, /** * Called when an option link is clicked or an option input content is @@ -960,38 +1397,32 @@ var SnippetOption = Widget.extend({ * @param {Event} ev */ _onOptionSelection: function (ev) { - var $opt = $(ev.target).closest('we-button, we-input'); - if (ev.isDefaultPrevented() || !$opt.length || !$opt.is(':hasData')) { - return; - } - - ev.preventDefault(); - this.__click = true; - this._select(false, $opt); - this.$target.trigger('snippet-option-change', [this]); + ev.stopPropagation(); + this._selectEventMutex.exec(() => { + return this._select(false, ev.data.widget).then(() => { + this.$target.trigger('snippet-option-change', [this]); + }); + }); }, /** * Called when an option link/menu is left -> reactivate the options that * were activated before previews. * * @private + * @param {Event} ev */ - _onOptionCancel: function () { - if (this.__click) { - return; - } - this._reset(); + _onOptionCancel: function (ev) { + ev.stopPropagation(); + this._selectEventMutex.exec(() => { + return this._select('reset', ev.data.widget); + }); }, }); +const registry = {}; //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -/** - * The registry object contains the list of available options. - */ -var registry = {}; - -registry.sizing = SnippetOption.extend({ +registry.sizing = SnippetOptionWidget.extend({ /** * @override */ @@ -1127,8 +1558,8 @@ registry.sizing = SnippetOption.extend({ /** * @override */ - setTarget: function () { - this._super.apply(this, arguments); + setTarget: async function () { + await this._super(...arguments); this._onResize(); }, @@ -1243,7 +1674,7 @@ registry['sizing_y'] = registry.sizing.extend({ /** * Handles the edition of snippet's background color classes. */ -registry.colorpicker = SnippetOption.extend({ +registry.colorpicker = SnippetOptionWidget.extend({ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], custom_events: { 'color_picked': '_onColorPicked', @@ -1276,14 +1707,15 @@ registry.colorpicker = SnippetOption.extend({ // Build the select element with a custom span to hold the color preview this.colorPreviewEl = document.createElement('span'); - const selectEl = this._buildSelectElement(this.data.string, { + const selectWidget = new SelectUserValueWidget(this, this.data.string, { classes: ['o_we_so_color_palette'], childNodes: [this.colorPalette.el], valueEl: this.colorPreviewEl, }); + await selectWidget.appendTo(document.createDocumentFragment()); // Replace the colorpicker UI with the select element - this.$el.empty().append(selectEl); + this.$el.empty().append(selectWidget.el); return _super(...args); }, /** @@ -1315,8 +1747,8 @@ registry.colorpicker = SnippetOption.extend({ /** * @override */ - _updateUI: function () { - this._super.apply(this, arguments); + _updateUI: async function () { + await this._super.apply(this, arguments); this.colorPreviewEl.style.backgroundColor = this.$target.css('background-color'); }, @@ -1331,9 +1763,10 @@ registry.colorpicker = SnippetOption.extend({ * @param {Event} ev */ _onColorPicked: function () { - this._updateUI(); - this.$target.closest('.o_editable').trigger('content_changed'); - this.$target.trigger('background-color-event', false); + this._updateUI().then(() => { + this.$target.closest('.o_editable').trigger('content_changed'); + this.$target.trigger('background-color-event', false); + }); }, /** * Called when a custom color is selected @@ -1371,46 +1804,25 @@ registry.colorpicker = SnippetOption.extend({ */ _onColorReset: function () { this._changeTargetColor(''); - this._updateUI(); - this.$target.trigger('content_changed'); + this._updateUI().then(() => { + this.$target.trigger('content_changed'); + }); }, }); /** * Handles the edition of snippet's background image. */ -registry.background = SnippetOption.extend({ +registry.background = SnippetOptionWidget.extend({ /** * @override */ start: function () { - // Build option UI controls - const editBgEl = document.createElement('we-button'); - editBgEl.dataset.chooseImage = 'true'; - editBgEl.dataset.noPreview = 'true'; - const iconEl = document.createElement('i'); - iconEl.classList.add('fa', 'fa-fw', 'fa-edit'); - this.editBgTextEl = document.createElement('span'); - editBgEl.appendChild(this.editBgTextEl); - editBgEl.appendChild(iconEl); - - this.removeBgEl = document.createElement('we-button'); - this.removeBgEl.classList.add('fa', 'fa-fw', 'fa-times'); - this.removeBgEl.title = _t("Remove the background"); - this.removeBgEl.dataset.background = ''; - this.removeBgEl.dataset.noPreview = 'true'; - - this.$el.append(this._buildRowElement(this.data.string, { - childNodes: [editBgEl, this.removeBgEl], - })); - - var res = this._super.apply(this, arguments); - // Initialize background and events this.bindBackgroundEvents(); this.__customImageSrc = this._getSrcFromCssValue(); - return res; + return this._super(...arguments); }, //-------------------------------------------------------------------------- @@ -1422,42 +1834,36 @@ registry.background = SnippetOption.extend({ * * @see this.selectClass for parameters */ - background: function (previewMode, value, $opt) { - if (previewMode === 'reset' && value === undefined) { + background: function (previewMode, widgetValue, params) { + if (previewMode === 'reset') { // No background has been selected and we want to reset back to the // original custom image - this._setCustomBackground(this.__customImageSrc); + this._setCustomBackground(this.__customImageSrc); // FIXME this is async... return; } - if (value && value.length) { - this.$target.css('background-image', 'url(\'' + value + '\')'); + if (widgetValue) { + this.$target.css('background-image', 'url(\'' + widgetValue + '\')'); this.$target.removeClass('oe_custom_bg').addClass('oe_img_bg'); } else { this.$target.css('background-image', ''); this.$target.removeClass('oe_img_bg oe_custom_bg'); } }, - /** - * @override - */ - selectClass: function (previewMode, value, $opt) { - this.background(previewMode, '', $opt); - this._super(previewMode, value ? (value + ' oe_img_bg') : value, $opt); - }, /** * Opens a media dialog to add a custom background image. * * @see this.selectClass for parameters */ - chooseImage: function (previewMode, value, $opt) { + chooseImage: function (previewMode, widgetValue, params) { var options = this._getMediaDialogOptions(); var media = this._getEditableMedia(); var _editor = new weWidgets.MediaDialog(this, options, media).open(); _editor.on('save', this, data => { - this._onSaveMediaDialog(data); - this.$target.trigger('content_changed'); + this._onSaveMediaDialog(data).then(() => { + this.$target.trigger('content_changed'); + }); }); _editor.on('closed', this, () => { if (media.classList.contains('o_we_fake_image')) { @@ -1484,8 +1890,8 @@ registry.background = SnippetOption.extend({ /** * @override */ - setTarget: function () { - this._super.apply(this, arguments); + setTarget: async function () { + await this._super(...arguments); // TODO should be automatic for all options as equal to the start method this.bindBackgroundEvents(); this.__customImageSrc = this._getSrcFromCssValue(); @@ -1551,10 +1957,10 @@ registry.background = SnippetOption.extend({ /** * @override */ - _updateUI: function () { - this._super.apply(this, arguments); + _updateUI: async function () { + await this._super.apply(this, arguments); var src = this._getSrcFromCssValue(); - this.removeBgEl.classList.toggle('d-none', !src); + this.removeBgWidget.el.classList.toggle('d-none', !src); if (src) { var split = src.split('/'); this.editBgTextEl.textContent = split[split.length - 1]; @@ -1562,17 +1968,49 @@ registry.background = SnippetOption.extend({ this.editBgTextEl.textContent = this._getDefaultTextContent(); } }, + /** + * @override + */ + _renderCustomWidgets: async function (uiFragment) { + // Build option UI controls + this.editBgTextEl = document.createElement('span'); + const iconEl = document.createElement('i'); + iconEl.classList.add('fa', 'fa-fw', 'fa-edit'); + const editBgWidget = this._registerUserValueWidget('we-button', this, '', { + dataAttributes: { + chooseImage: 'true', + noPreview: 'true', + }, + childNodes: [this.editBgTextEl, iconEl], + }); + await editBgWidget.appendTo(document.createDocumentFragment()); + + this.removeBgWidget = this._registerUserValueWidget('we-button', this, '', { + classes: ['fa', 'fa-fw', 'fa-times'], + dataAttributes: { + background: '', + noPreview: 'true', + }, + }); + await this.removeBgWidget.appendTo(document.createDocumentFragment()); + this.removeBgWidget.el.title = _t("Remove the background"); + + return uiFragment.appendChild(_buildRowElement(this.data.string, { + childNodes: [editBgWidget.el, this.removeBgWidget.el], + })); + }, /** * Sets the given value as custom background image. * * @private * @param {string} value + * @returns {Promise} */ - _setCustomBackground: function (value) { + _setCustomBackground: async function (value) { this.__customImageSrc = value; - this.background(false, this.__customImageSrc); + this.background(false, this.__customImageSrc, {}); this.$target.toggleClass('oe_custom_bg', !!value); - this._updateUI(); + await this._updateUI(); this.$target.trigger('snippet-option-change', [this]); }, @@ -1607,16 +2045,17 @@ registry.background = SnippetOption.extend({ * * @private * @param {Object} data + * @returns {Promise} */ - _onSaveMediaDialog: function (data) { - this._setCustomBackground(data.src); + _onSaveMediaDialog: async function (data) { + await this._setCustomBackground(data.src); }, }); /** * Handles the edition of snippets' background image position. */ -registry.BackgroundPosition = SnippetOption.extend({ +registry.BackgroundPosition = SnippetOptionWidget.extend({ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], /** @@ -1665,8 +2104,8 @@ registry.BackgroundPosition = SnippetOption.extend({ * * @see this.selectClass for params */ - backgroundType: function (previewMode, value, $opt) { - this.$target.toggleClass('o_bg_img_opt_repeat', value === 'repeat-pattern'); + 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', ''); }, @@ -1675,7 +2114,7 @@ registry.BackgroundPosition = SnippetOption.extend({ * * @see this.selectClass for params */ - backgroundPositionOverlay: function (previewMode, value, $opt) { + backgroundPositionOverlay: function (previewMode, widgetValue, params) { const position = this.$target.css('background-position').split(' ').map(v => parseInt(v)); // Convert % values to pixels (because mouse movement is in pixels) const delta = this._getBackgroundDelta(); @@ -1687,11 +2126,32 @@ registry.BackgroundPosition = SnippetOption.extend({ 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 + */ + _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. @@ -1787,17 +2247,14 @@ registry.BackgroundPosition = SnippetOption.extend({ }, /** * Disables background position if no background image, disables size inputs - * in cover mode, and activates the proper select option. + * in cover mode. * * @override */ - _setActive: function () { + _updateUI: async function () { + await this._super.apply(this, arguments); this.$el.toggleClass('d-none', this.$target.css('background-image') === 'none'); this.$el.find('we-input').toggleClass('d-none', this.$target.css('background-repeat') !== 'repeat'); - this.$el.find('[data-background-type]').removeClass('active') - .filter(`[data-background-type=${this.$target.css('background-repeat') === 'repeat' ? 'repeat-pattern' : 'cover'}]`).addClass('active'); - - this._super.apply(this, arguments); }, /** * Returns the src value from a css value related to a background image @@ -1914,7 +2371,7 @@ registry.BackgroundPosition = SnippetOption.extend({ * Allows to replace a text value with the name of a database record. * @todo replace this mechanism with real backend m2o field ? */ -registry.many2one = SnippetOption.extend({ +registry.many2one = SnippetOptionWidget.extend({ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], /** * @override @@ -2074,9 +2531,19 @@ registry.many2one = SnippetOption.extend({ }); return { - Class: SnippetOption, + SnippetOptionWidget: SnippetOptionWidget, + snippetOptionRegistry: registry, + + UserValueWidget: UserValueWidget, + userValueWidgetsRegistry: userValueWidgetsRegistry, + + addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes, + buildElement: _buildElement, + buildTitleElement: _buildTitleElement, + buildRowElement: _buildRowElement, + + // Other names for convenience + Class: SnippetOptionWidget, registry: registry, - CSS_SHORTHANDS: CSS_SHORTHANDS, - CSS_UNITS_CONVERSION: CSS_UNITS_CONVERSION, }; }); diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index f1f69a952d6..404bccc7b53 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -398,6 +398,14 @@ body.editor_enable.editor_has_snippets { } } + we-row we-input { + flex-wrap: wrap; + + > we-title { + min-width: 95%; + } + } + .dropdown-menu { // FIXME temporary fix for m2o option for example position: static !important; diff --git a/addons/web_editor/views/editor.xml b/addons/web_editor/views/editor.xml index e775132ca87..e61833e4e77 100644 --- a/addons/web_editor/views/editor.xml +++ b/addons/web_editor/views/editor.xml @@ -131,6 +131,7 @@