diff options
| author | qsm-odoo <qsm@odoo.com> | 2019-11-18 16:56:42 +0000 |
|---|---|---|
| committer | qsm-odoo <qsm@odoo.com> | 2019-12-01 15:49:57 +0000 |
| commit | b38253f400596dc6c96c2aface43684a56b24d26 (patch) | |
| tree | c1bee14687b795a3db8a192118b1ef145ba11c5a | |
| parent | 75ab52af9c067b4cbc5a67083e8d02736e11bc3c (diff) | |
[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
16 files changed, 1656 insertions, 1079 deletions
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<string[]>} + */ +const CSS_SHORTHANDS = { + 'border-width': ['border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'], + 'border-radius': ['border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius'], +}; +/** + * 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 @@ -145,6 +145,15 @@ var SnippetEditor = Widget.extend({ //-------------------------------------------------------------------------- /** + * 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. */ @@ -166,6 +175,17 @@ var SnippetEditor = Widget.extend({ }); }, /** + * 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. */ cover: function () { @@ -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', }, /** @@ -1669,21 +1687,6 @@ var SnippetsMenu = Widget.extend({ 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. * * @private @@ -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<string[]>} + * @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); +} /** - * Computes the number of "px" needed to make a "rem" unit. Subsequent calls - * returns the cached computed value. + * @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 {boolean} [toRem=false] - * @returns {float} - number of px by rem if 'toRem' is false - * - the inverse otherwise + * @param {string} [title] - @see _buildElement + * @param {Object} [options] - @see _buildElement + * @param {HTMLElement[]} [options.childNodes] + * @returns {HTMLElement} */ -function _computePxByRem(toRem) { - if (_computePxByRem.PX_BY_REM === undefined) { - const htmlStyle = window.getComputedStyle(document.documentElement); - _computePxByRem.PX_BY_REM = parseFloat(htmlStyle['font-size']); +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 toRem ? (1 / _computePxByRem.PX_BY_REM) : _computePxByRem.PX_BY_REM; + + return groupEl; } +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +/** + * Base class for components to be used in snippet options widgets to retrieve + * user values. + */ +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 + * <input/> 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); - }); - - // 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); + willStart: async function () { + await this._super(...arguments); + return this._renderOriginalXML().then(uiFragment => { + this.uiFragment = uiFragment; }); - - 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); + if (widgetValue) { + this.$target[0].classList.add(...widgetValue.trim().split(/\s+/g)); } - this.$target[0].dataset[dataName] = isNaN(value) ? defaultValue : parseFloat(value.toFixed(3)); - - var extraClass = weInput.dataset.extraClass; - if (extraClass) { - this.$target.toggleClass(extraClass, hasUserValue); + }, + /** + * Default option method which allows to select a value and set it on the + * associated snippet as a data attribute. The name of the data attribute is + * given by the attributeName parameter. + * + * @param {boolean} previewMode - @see this.selectClass + * @param {string} widgetValue + * @param {Object} params + */ + 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 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 css style. The name of the css property is + * given by the cssProperty 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 - */ - 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; + * @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, ''); } - const styles = window.getComputedStyle(this.$target[0]); - cssProps.forEach((cssProp, i) => { - const cssValue = subValuesByInput.map(inputSubValues => inputSubValues[i]).join(' '); - if (styles[cssProp] !== cssValue) { - this.$target[0].style.setProperty(cssProp, cssValue, 'important'); - } - }); - - 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; + if (params.extraClass) { + this.$target.removeClass(params.extraClass); } - 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 styles = window.getComputedStyle(this.$target[0]); + let hasUserValue = false; + for (let i = cssProps.length - 1; i > 0; i--) { + hasUserValue = applyCSS.call(this, cssProps[i], values.pop(), styles) || hasUserValue; } - const dataName = $ancestor[0].dataset.attributeName; - if (!dataName) { - return; + 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; } - 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); } }, @@ -399,16 +1085,23 @@ var SnippetOption = Widget.extend({ 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 * will then be handled by this function. * * @param {string} name - an identifier for a type of update * @param {*} data + * @returns {Promise} */ - notify: function (name, data) { + 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,219 +1124,187 @@ 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} - */ - _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]; + * @param {string} methodName + * @param {Object} params + * @returns {Promise<string>|string} + */ + _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} + * @static + * @param {HTMLElement} el + * @returns {Object} */ - _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; + _extraInfoFromDescriptionElement: function (el) { + return { + title: el.getAttribute('string'), + options: { + classes: el.classList, + dataAttributes: el.dataset, + childNodes: [...el.childNodes], + }, + }; }, /** - * 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} + * @param {string} widgetName + * @param {UserValueWidget|this|null} parent + * @param {string} title + * @param {Object} options + * @returns {UserValueWidget} */ - _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)); + _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 groupEl; + return widget; }, /** - * Build the correct DOM for a we-input element. - * * @private - * @param {string} [title] - * @param {Object} [options] - @see this._buildElement + * @param {HTMLElement} uiFragment + * @returns {Promise} */ - _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; + _renderCustomWidgets: function (uiFragment) { + return Promise.resolve(); }, /** - * 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} + * @param {HTMLElement} uiFragment + * @returns {Promise} */ - _buildSelectElement: function (title, options) { - const selectEl = this._buildElement('we-select', title, options); + _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); - if (options && options.valueEl) { - selectEl.appendChild(options.valueEl); - } + // 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); + }); - const menuTogglerEl = document.createElement('we-toggler'); - selectEl.appendChild(menuTogglerEl); + // Load widgets + await this._renderCustomXML(uiFragment); + await this._renderXMLWidgets(uiFragment); + await this._renderCustomWidgets(uiFragment); - var menuEl = document.createElement('we-select-menu'); - if (options && options.childNodes) { - options.childNodes.forEach(node => menuEl.appendChild(node)); + const validMethodNames = []; + for (const key in this) { + validMethodNames.push(key); } - selectEl.appendChild(menuEl); + this._userValueWidgets.forEach(widget => { + widget.loadMethodsData(validMethodNames); + }); - return selectEl; + return uiFragment; }, + /** * @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} + * @param {HTMLElement} parentEl + * @param {SnippetOptionWidget|UserValueWidget} parentWidget + * @returns {Promise} */ - _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); + _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); }, /** - * 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} + * @private + * @param {function<Promise<jQuery>>} [callback] + * @returns {Promise} */ - _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} !`); + _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 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. - */ - _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); - }); - _.each(this.__methodNames, function (methodName) { - self[methodName]('reset'); + + 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,223 +1314,60 @@ 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) { - 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)) { + _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; } - 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]); - - 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(); - } + await Promise.all(proms); - 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); }, //-------------------------------------------------------------------------- @@ -876,63 +1375,6 @@ var SnippetOption = Widget.extend({ //-------------------------------------------------------------------------- /** - * @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,16 +1834,16 @@ 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', ''); @@ -1439,25 +1851,19 @@ registry.background = SnippetOption.extend({ } }, /** - * @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]; @@ -1563,16 +1969,48 @@ registry.background = SnippetOption.extend({ } }, /** + * @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,12 +2126,33 @@ 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 @@ <xpath expr="script[last()]" position="after"> <script type="text/javascript" src="/web_editor/static/lib/vkbeautify/vkbeautify.0.99.00.beta.js"/> <script type="text/javascript" src="/web_editor/static/src/js/common/ace.js"/> + <script type="text/javascript" src="/web_editor/static/src/js/common/utils.js"/> <script type="text/javascript" src="/web_editor/static/src/js/wysiwyg/root.js"/> </xpath> </template> diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js index c42c11b738c..2975e89ae95 100644 --- a/addons/website/static/src/js/content/snippets.animation.js +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -881,7 +881,7 @@ registry.gallery = publicWidget.Widget.extend({ srcs: urls, index: idx, dim: dimensions, - interval: milliseconds, + interval: milliseconds || 0, id: _.uniqueId('slideshow_'), })); $modal.modal({ diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 6edbc840aa4..6eb413558e8 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -19,14 +19,7 @@ options.Class.include({ /** * @override */ - setDataAttribute: function (previewMode, dataName, $opt) { - this._super(...arguments); - this._refreshPublicWidgets(); - }, - /** - * @override - */ - selectDataAttribute: function (previewMode, value, $opt) { + selectDataAttribute: function (previewMode, widgetValue, params) { this._super(...arguments); this._refreshPublicWidgets(); }, @@ -86,19 +79,20 @@ options.registry.background.include({ /** * @override */ - _setActive: function () { - this._super(...arguments); - if (this._hasBgvideo()) { - this.$el.find('[data-choose-image]').addClass('active'); + _computeWidgetState: function (methodName, params) { + if (methodName === 'chooseImage') { + return this._hasBgvideo() ? 'true' : ''; } + return this._super(...arguments); }, /** * Updates the background video used by the snippet. * * @private * @see this.selectClass for parameters + * @returns {Promise} */ - _setBgVideo: function (previewMode, value) { + _setBgVideo: async function (previewMode, value) { this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true); if (previewMode !== false) { @@ -113,7 +107,7 @@ options.registry.background.include({ delete target.dataset.bgVideoSrc; } this._refreshPublicWidgets(); - this._updateUI(); + await this._updateUI(); }, /** * Returns whether the current target has a background video or not. @@ -142,15 +136,16 @@ options.registry.background.include({ /** * @override */ - _onSaveMediaDialog: function (data) { + _onSaveMediaDialog: async function (data) { if (!data.bgVideoSrc) { - this._setBgVideo(false); - this._super(...arguments); - return; + const _super = this._super.bind(this); + const args = arguments; + await this._setBgVideo(false); + return _super(...args); } // if the user chose a video, only add the video without removing the // background - this._setBgVideo(false, data.bgVideoSrc); + await this._setBgVideo(false, data.bgVideoSrc); }, }); @@ -416,8 +411,8 @@ options.registry.CarouselItem = options.Class.extend({ * * @see this.selectClass for parameters */ - slide: function (previewMode, value) { - switch (value) { + slide: function (previewMode, widgetValue, params) { + switch (widgetValue) { case 'left': this.$controls.filter('.carousel-control-prev')[0].click(); break; @@ -458,7 +453,7 @@ options.registry.navTabs = options.Class.extend({ * * @see this.selectClass for parameters */ - addTab: function (previewMode, value, $opt) { + addTab: function (previewMode, widgetValue, params) { var $activeItem = this.$navLinks.filter('.active').parent(); var $activePane = this.$tabPanes.filter('.active'); @@ -477,7 +472,7 @@ options.registry.navTabs = options.Class.extend({ * * @see this.selectClass for parameters */ - removeTab: function (previewMode, value, $opt) { + removeTab: function (previewMode, widgetValue, params) { var self = this; var $activeLink = this.$navLinks.filter('.active'); @@ -531,8 +526,8 @@ options.registry.navTabs = options.Class.extend({ * @private * @override */ - _updateUI: function () { - this._super.apply(this, arguments); + _updateUI: async function () { + await this._super.apply(this, arguments); this.$el.filter('[data-remove-tab]').toggleClass('d-none', this.$tabPanes.length <= 2); }, }); @@ -607,8 +602,9 @@ options.registry.layout_column = options.Class.extend({ * * @see this.selectClass for parameters */ - selectCount: function (previewMode, value, $opt) { - this._updateColumnCount(value - this.$target.children().length); + selectCount: function (previewMode, widgetValue, params) { + const nbColumns = parseInt(widgetValue); + this._updateColumnCount(nbColumns - this.$target.children().length); }, //-------------------------------------------------------------------------- @@ -616,6 +612,15 @@ options.registry.layout_column = options.Class.extend({ //-------------------------------------------------------------------------- /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'selectCount') { + return '' + this.$target.children().length; + } + return this._super(...arguments); + }, + /** * Adds new columns which are clones of the last column or removes the * last x columns. * @@ -664,14 +669,6 @@ options.registry.layout_column = options.Class.extend({ $columns.first().addClass('offset-lg-' + colOffset); } }, - /** - * @override - */ - _setActive: function () { - this._super.apply(this, arguments); - this.$el.find('[data-select-count]').removeClass('active') - .filter('[data-select-count=' + this.$target.children().length + ']').addClass('active'); - }, }); options.registry.parallax = options.Class.extend({ @@ -931,7 +928,7 @@ options.registry.ul = options.Class.extend({ /** * @override */ - toggleClass: function () { + selectClass: function () { this._super.apply(this, arguments); this.trigger_up('widgets_stop_request', { @@ -1121,8 +1118,8 @@ options.registry.gallery = options.Class.extend({ 'data-index': ++index, }).appendTo($container); } - self._reset(); - self.trigger_up('cover_update'); + this.mode('reset', this.getMode()); + this.trigger_up('cover_update'); this._updateUI(); }); dialog.open(); @@ -1133,11 +1130,11 @@ options.registry.gallery = options.Class.extend({ * * @see this.selectClass for parameters */ - columns: function (previewMode, value) { - this.$target.attr('data-columns', value); + columns: function (previewMode, widgetValue, params) { + const nbColumns = parseInt(widgetValue || '1'); + this.$target.attr('data-columns', nbColumns); - var $activeMode = this.$el.find('.active[data-mode]'); - this.mode(null, $activeMode.data('mode'), $activeMode); + this.mode(previewMode, this.getMode(), {}); // TODO improve }, /** * Get the image target's layout mode (slideshow, masonry, grid or nomode). @@ -1182,8 +1179,8 @@ options.registry.gallery = options.Class.extend({ * Allows to changes the interval of automatic slideshow (not active in * edit mode). */ - interval: function (previewMode, value) { - this.$target.find('.carousel:first').attr('data-interval', value); + interval: function (previewMode, widgetValue) { + this.$target.find('.carousel:first').attr('data-interval', widgetValue || '0'); }, /** * Displays the images with the "masonry" layout. @@ -1226,12 +1223,13 @@ options.registry.gallery = options.Class.extend({ * * @see this.selectClass for parameters */ - mode: function (previewMode, value, $opt) { + mode: function (previewMode, widgetValue, params) { + widgetValue = widgetValue || 'slideshow'; // FIXME should not be needed this.$target.css('height', ''); - this[value](); + this[widgetValue](); this.$target .removeClass('o_nomode o_masonry o_grid o_slideshow') - .addClass('o_' + value); + .addClass('o_' + widgetValue); this.trigger_up('cover_update'); }, /** @@ -1322,11 +1320,11 @@ options.registry.gallery = options.Class.extend({ * * @override */ - notify: function (name, data) { - this._super.apply(this, arguments); + notify: async function (name, data) { + await this._super(...arguments); if (name === 'image_removed') { data.$image.remove(); // Force the removal of the image before reset - this._reset(); + this.mode('reset', this.getMode()); } else if (name === 'image_index_request') { var imgs = this._getImages(); var position = _.indexOf(imgs, data.$image[0]); @@ -1351,7 +1349,7 @@ options.registry.gallery = options.Class.extend({ // indexes were not the same as positions. $(img).attr('data-index', index); }); - this._reset(); + this.mode('reset', this.getMode()); } }, @@ -1360,6 +1358,44 @@ options.registry.gallery = options.Class.extend({ //-------------------------------------------------------------------------- /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'mode': { + let activeModeName = 'slideshow'; + for (const modeName of params.possibleValues) { + if (this.$target.hasClass(`o_${modeName}`)) { + activeModeName = modeName; + break; + } + } + this.activeMode = activeModeName; + return activeModeName; + } + case 'interval': { + const carousel = this.$target[0].querySelector('.carousel'); + return (carousel && carousel.dataset.interval || '0'); + } + case 'columns': { + return `${this._getColumns()}`; + } + case 'styling': { + const img = this.$target[0].querySelector('img'); + if (!img) { + return ''; + } + for (const className of params.possibleValues) { + if (className && img.classList.contains(className)) { + return className; + } + } + return ''; + } + } + return this._super(...arguments); + }, + /** * Returns the images, sorted by index. * * @private @@ -1407,57 +1443,17 @@ options.registry.gallery = options.Class.extend({ /** * @override */ - _setActive: function () { - this._super(...arguments); + _updateUI: async function () { + await this._super(...arguments); - var activeModeSelectors = []; - for (const className of this.$target[0].classList) { - if (className.startsWith('o_')) { - activeModeSelectors.push('[data-mode="' + className.substring(2) + '"]'); - } - } - var activeMode = this.$el.find('[data-mode]') - .removeClass('active') - .filter(activeModeSelectors.join(', ')) - .addClass('active') - .data('mode'); - - var carousel = this.$target[0].querySelector('.carousel'); - var activeInterval = carousel ? (carousel.dataset.interval || 0) : undefined; - var $intervalOptions = this.$el.find('[data-interval]'); - $intervalOptions.removeClass('active') - .filter('[data-interval="' + activeInterval + '"]') - .addClass('active'); - $intervalOptions.closest('we-select')[0] - .classList.toggle('d-none', activeMode !== 'slideshow'); + this.$el.find('[data-interval]').closest('we-select')[0] + .classList.toggle('d-none', this.activeMode !== 'slideshow'); - var columns = this._getColumns(); - var $columnOptions = this.$el.find('[data-columns]'); - $columnOptions.removeClass('active') - .filter('[data-columns="' + columns + '"]') - .addClass('active'); - $columnOptions.closest('we-select')[0] - .classList.toggle('d-none', !(activeMode === 'grid' || activeMode === 'masonry')); + this.$el.find('[data-columns]').closest('we-select')[0] + .classList.toggle('d-none', !(this.activeMode === 'grid' || this.activeMode === 'masonry')); this.el.querySelector('.o_w_image_spacing_option') - .classList.toggle('d-none', activeMode === 'slideshow'); - - var $stylingOptions = this.$el.find('[data-styling]'); - $stylingOptions.removeClass('active'); - var img = this.$target[0].querySelector('img'); - var activeStyleSelectors = []; - if (img) { - for (const className of img.classList) { - activeStyleSelectors.push('[data-styling="' + className + '"]'); - } - } - var $toEnable = activeStyleSelectors.length - ? $stylingOptions.filter(activeStyleSelectors.join(', ')) - : null; - if (!$toEnable || !$toEnable.length) { - $toEnable = $stylingOptions.first(); - } - $toEnable.addClass('active'); + .classList.toggle('d-none', this.activeMode === 'slideshow'); }, }); @@ -1486,14 +1482,14 @@ options.registry.gallery_img = options.Class.extend({ * * @see this.selectClass for parameters */ - position: function (previewMode, value) { + position: function (previewMode, widgetValue, params) { this.trigger_up('deactivate_snippet'); this.trigger_up('option_update', { optionName: 'gallery', name: 'image_index_request', data: { $image: this.$target, - position: value, + position: widgetValue, }, }); }, @@ -1510,7 +1506,7 @@ options.registry.topMenuTransparency = options.Class.extend({ * * @see this.selectClass for params */ - transparent: function (previewMode, value, $opt) { + transparent: function (previewMode, widgetValue, params) { var self = this; this.trigger_up('action_demand', { actionName: 'toggle_page_option', @@ -1531,16 +1527,17 @@ options.registry.topMenuTransparency = options.Class.extend({ /** * @override */ - _setActive: function () { - this._super.apply(this, arguments); - - this.trigger_up('action_demand', { - actionName: 'get_page_option', - params: ['header_overlay'], - onSuccess: value => { - this.$el.find('[data-transparent]').toggleClass('active', !!value); - }, - }); + _computeWidgetState: function (methodName, params) { + if (methodName === 'transparent') { + return new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: v => resolve(v ? 'true' : ''), + }); + }); + } + return this._super(...arguments); }, }); @@ -1630,7 +1627,7 @@ options.registry.anchor = options.Class.extend({ /** * @see this.selectClass for parameters */ - openAnchorDialog: function (previewMode, value, $opt) { + openAnchorDialog: function (previewMode, widgetValue, params) { var self = this; var buttons = [{ text: _t("Save & copy"), @@ -1757,14 +1754,14 @@ options.registry.CoverProperties = options.Class.extend({ /** * @see this.selectClass for parameters */ - clear: function (previewMode, value, $opt) { - this.selectClass(previewMode, '', $()); + clear: function (previewMode, widgetValue, params) { + this.$target.removeClass('o_record_has_cover'); this.$image.css('background-image', ''); }, /** * @see this.selectClass for parameters */ - change: function (previewMode, value, $opt) { + change: function (previewMode, widgetValue, params) { var $image = $('<img/>'); var background = this.$image.css('background-image'); if (background && background !== 'none') { @@ -1780,8 +1777,7 @@ options.registry.CoverProperties = options.Class.extend({ var src = image.src; this.$image.css('background-image', src ? ('url(' + src + ')') : ''); if (!this.$target.hasClass('o_record_has_cover')) { - var $opt = this.$el.find('.o_record_cover_opt_size_default[data-select-class]'); - this.selectClass(previewMode, $opt.data('selectClass'), $opt); + this.$el.find('.o_record_cover_opt_size_default[data-select-class]').click(); } this._updateUI(); }); @@ -1789,16 +1785,16 @@ options.registry.CoverProperties = options.Class.extend({ /** * @see this.selectClass for parameters */ - filterValue: function (previewMode, value, $opt) { - this.$filter.css('opacity', value); + filterValue: function (previewMode, widgetValue, params) { + this.$filter.css('opacity', widgetValue || 0); }, /** * @see this.selectClass for parameters */ - filterColor: function (previewMode, value, $opt) { + filterColor: function (previewMode, widgetValue, params) { this.$filter.removeClass(this.filterColorClasses); - if (value) { - this.$filter.addClass(value); + if (widgetValue) { + this.$filter.addClass(widgetValue); } var $firstVisibleFilterOpt = this.$filterValueOpts.eq(1); @@ -1812,12 +1808,32 @@ options.registry.CoverProperties = options.Class.extend({ //-------------------------------------------------------------------------- /** - * @private * @override */ - _setActive: function () { - this._super.apply(this, arguments); + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'filterValue': { + return parseFloat(this.$filter.css('opacity')).toFixed(1); + } + case 'filterColor': { + const classes = this.filterColorClasses.split(' '); + for (const className of classes) { + if (this.$filter.hasClass(className)) { + return className; + } + } + return ''; + } + } + return this._super(...arguments); + }, + /** + * @private + */ + _updateUI: async function () { + await this._super(...arguments); + // Only show options which are useful to the current cover _.each(this.$el.children(), el => { var $el = $(el); @@ -1834,27 +1850,14 @@ options.registry.CoverProperties = options.Class.extend({ }); } }); - this.$el.find('[data-clear]').toggleClass('d-none', !this.$target.hasClass('o_record_has_cover')); - this.$filterValueOpts.removeClass('active'); - this.$filterColorOpts.removeClass('active'); - - var activeFilterValue = this.$filterValueOpts - .filter((i, el) => { - return (parseFloat($(el).data('filterValue')).toFixed(1) === parseFloat(this.$filter.css('opacity')).toFixed(1)); - }).addClass('active').data('filterValue'); - - var activeFilterColor = this.$filterColorOpts - .filter((i, el) => { - return this.$filter.hasClass($(el).data('filterColor')); - }).addClass('active').data('filterColor'); - + // Update saving dataset this.$target[0].dataset.coverClass = this.$el.find('.active[data-cover-opt="size"]').data('selectClass') || ''; this.$target[0].dataset.textSizeClass = this.$el.find('.active[data-cover-opt="text_size"]').data('selectClass') || ''; this.$target[0].dataset.textAlignClass = this.$el.find('.active[data-cover-opt="text_align"]').data('selectClass') || ''; - this.$target[0].dataset.filterValue = activeFilterValue || 0.0; - this.$target[0].dataset.filterColor = activeFilterColor || ''; + this.$target[0].dataset.filterValue = this.$filterValueOpts.filter('.active').data('filterValue') || 0.0; + this.$target[0].dataset.filterColor = this.$filterColorOpts.filter('.active').data('filterColor') || ''; }, }); @@ -1878,35 +1881,6 @@ options.registry.SectionStretch = options.Class.extend({ return this._super.apply(this, arguments); }, - - //-------------------------------------------------------------------------- - // Options - //-------------------------------------------------------------------------- - - /** - * @see this.selectClass for parameters - */ - toggleContainerFluid: function (previewMode, value, $opt) { - var isFluid = this.$el.find('[data-toggle-container-fluid]').hasClass('active'); - this.$target.toggleClass('container', !isFluid) - .toggleClass('container-fluid', isFluid); - if (previewMode !== 'reset') { - this.$target.toggleClass('container').toggleClass('container-fluid'); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _setActive: function () { - this._super.apply(this, arguments); - var isFluid = this.$target.hasClass('container-fluid'); - this.$el.find('[data-toggle-container-fluid]').toggleClass('active', isFluid); - }, }); /** @@ -1922,10 +1896,6 @@ options.registry.SnippetMove = options.Class.extend({ $overlayArea.prepend($buttons[0]); $overlayArea.append($buttons[1]); - // TODO this is kinda hack but not worth a complex system while no - // other use case is implemented. - $buttons.on('click', this._onOptionSelection.bind(this)); - return this._super(...arguments); }, @@ -1938,8 +1908,8 @@ options.registry.SnippetMove = options.Class.extend({ * * @see this.selectClass for parameters */ - moveSnippet: function (previewMode, value, $opt) { - switch (value) { + moveSnippet: function (previewMode, widgetValue, params) { + switch (widgetValue) { case 'prev': this.$target.prev().before(this.$target); break; diff --git a/addons/website/views/snippets.xml b/addons/website/views/snippets.xml index c8c89c0bebe..a4caf8333c3 100644 --- a/addons/website/views/snippets.xml +++ b/addons/website/views/snippets.xml @@ -1108,7 +1108,7 @@ <we-divider/> <we-button data-select-class="s_carousel_default">Default</we-button> </we-select> - <we-input string="Speed" data-set-data-attribute="interval" data-unit="s" data-save-unit="ms" data-step="0.1"/> + <we-input string="Speed" data-select-data-attribute="0s" data-attribute-name="interval" data-unit="s" data-save-unit="ms" data-step="0.1"/> </div> <div data-js="CarouselItem" @@ -1238,8 +1238,10 @@ <we-button class="fas fa-fw fa-expand-arrows-alt" title="Background Position" data-background-position-overlay="true" data-no-preview="true"/> </we-row> - <we-input string="Background width" data-set-style="background-size" data-default-value="auto"/> - <we-input string="Background height" data-set-style="background-size" data-default-value="auto"/> + <we-multi data-css-property="background-size"> + <we-input string="Width" data-select-style="auto" data-unit="px"/> + <we-input string="Height" data-select-style="auto" data-unit="px"/> + </we-multi> </div> <!-- Parallax --> @@ -1275,9 +1277,9 @@ <div data-selector="section .row > div" data-exclude=".s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .o_gallery .row > div"> - <we-input string="Border" data-set-style="border-width" data-extra-class="border"/> - <we-input string="Rounded" data-set-style="border-radius" data-extra-class="rounded"/> - <we-checkbox string="Shadow" data-toggle-class="shadow"/> + <we-input string="Border" data-select-style="0" data-css-property="border-width" data-unit="px" data-extra-class="border"/> + <we-input string="Rounded" data-select-style="0" data-css-property="border-radius" data-unit="px" data-extra-class="rounded"/> + <we-checkbox string="Shadow" data-select-class="shadow"/> </div> <div data-js="sizing_y" @@ -1301,7 +1303,7 @@ <div data-js="ul" data-selector=":not(li) > ul:has(ul,ol), :not(li) > ol:has(ul,ol)"> - <we-checkbox string="Folded list" data-toggle-class="o_ul_folded"/> + <we-checkbox string="Folded list" data-select-class="o_ul_folded"/> </div> <div data-js="menu_data" @@ -1391,7 +1393,7 @@ <div data-js="SectionStretch" data-selector="section" data-target="> .container, > .container-fluid"> - <we-checkbox string="Stretch" data-toggle-container-fluid=""/> + <we-checkbox string="Stretch" data-select-class="container|container-fluid" data-no-preview="true"/> </div> </template> </odoo> 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 6000b2d27a7..af6235f2e98 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 @@ -5,59 +5,29 @@ var sOptions = require('web_editor.snippets.options'); var wUtils = require('website.utils'); sOptions.registry.js_get_posts_selectBlog = sOptions.Class.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + /** * @override */ - start: function () { - var def = this._rpc({ + _renderCustomXML: function (uiFragment) { + return this._rpc({ model: 'blog.blog', method: 'search_read', args: [wUtils.websiteDomain(this), ['name']], }).then(blogs => { - var allBlogsEl = this.el.querySelector('[data-filter-by-blog-id="0"]'); - var menuEl = allBlogsEl.parentNode; + const menuEl = uiFragment.querySelector('[name="blog_selection"]'); for (const blog of blogs) { - let el = allBlogsEl.cloneNode(); - el.dataset.filterByBlogId = blog.id; + const el = document.createElement('we-button'); + el.dataset.selectDataAttribute = blog.id; el.textContent = blog.name; menuEl.appendChild(el); } - this._updateUI(); + return this._updateUI(); }); - - return Promise.all([this._super.apply(this, arguments), def]); - }, - - //-------------------------------------------------------------------------- - // Options - //-------------------------------------------------------------------------- - - /** - * @see this.selectClass for parameters - */ - filterByBlogId: function (previewMode, value, $opt) { - value = parseInt(value); - this.$target.attr('data-filter-by-blog-id', value).data('filterByBlogId', value); - this.trigger_up('widgets_start_request', { - editableMode: true, - $target: this.$target, - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @override - */ - _setActive: function () { - this._super.apply(this, arguments); - - var activeBlogId = this.$target.data('filterByBlogId') || 0; - - this.$el.find('[data-filter-by-blog-id]').removeClass('active'); - this.$el.find('[data-filter-by-blog-id=' + activeBlogId + ']').addClass('active'); }, }); }); diff --git a/addons/website_blog/static/src/js/website_blog.editor.js b/addons/website_blog/static/src/js/website_blog.editor.js index 4e2c136b0f1..a066451e764 100644 --- a/addons/website_blog/static/src/js/website_blog.editor.js +++ b/addons/website_blog/static/src/js/website_blog.editor.js @@ -111,8 +111,8 @@ options.registry.CoverProperties.include({ /** * @override */ - _updateUI: function () { - this._super(...arguments); + _updateUI: async function () { + await this._super(...arguments); var isRegularCover = this.$target.is('.o_wblog_post_page_cover_regular'); var $coverFull = this.$el.find('[data-select-class*="cover_full"]'); var $coverMid = this.$el.find('[data-select-class*="cover_mid"]'); diff --git a/addons/website_blog/views/snippets.xml b/addons/website_blog/views/snippets.xml index 388bf6e0fbb..3fe49ad3912 100644 --- a/addons/website_blog/views/snippets.xml +++ b/addons/website_blog/views/snippets.xml @@ -11,8 +11,8 @@ <template id="s_latest_posts_options" inherit_id="website.snippet_options"> <xpath expr="//div[@data-js='background']" position="before"> <div data-js="js_get_posts_selectBlog" data-selector=".s_latest_posts, .s_latest_posts_big_picture" data-target=".js_get_posts" data-no-check="true"> - <we-select string="Choose a blog" data-no-preview="true"> - <we-button data-filter-by-blog-id="0">All blogs</we-button> + <we-select string="Choose a blog" data-no-preview="true" name="blog_selection" data-attribute-name="filterByBlogId"> + <we-button data-select-data-attribute="0">All blogs</we-button> <!-- the blog list will be generated in js --> </we-select> </div> diff --git a/addons/website_form/static/src/js/website_form_editor.js b/addons/website_form/static/src/js/website_form_editor.js index b3960224e04..84545628a16 100644 --- a/addons/website_form/static/src/js/website_form_editor.js +++ b/addons/website_form/static/src/js/website_form_editor.js @@ -91,7 +91,7 @@ odoo.define('website_form_editor', function (require) { }, // Choose a model modal - website_form_model_modal: function (previewMode, value, $li) { + website_form_model_modal: function (previewMode, widgetValue, params) { var self = this; this._rpc({ model: "ir.model", @@ -224,7 +224,7 @@ odoo.define('website_form_editor', function (require) { }, // Choose a field modal - website_form_field_modal: function (previewMode, value, $li) { + website_form_field_modal: function (previewMode, widgetValue, params) { var self = this; this.fields().then(function (fields) { @@ -261,13 +261,13 @@ odoo.define('website_form_editor', function (require) { }, // Create a custom field - website_form_custom_field: function (previewMode, value, $li) { - var default_field_name = 'Custom ' + $li.text(); + website_form_custom_field: function (previewMode, widgetValue, params) { + var default_field_name = 'Custom ' + this.el.querySelector(`[data-website_form_custom_field="${widgetValue}"]`).textContent; // TODO improve this.append_field({ name: default_field_name, string: default_field_name, custom: true, - type: value, + type: widgetValue, // Default values for x2many fields records: [ { @@ -302,7 +302,7 @@ odoo.define('website_form_editor', function (require) { }, // Re-render the field and replace the current one - // website_form_editor_field_reset: function(previewMode, value, $li) { + // website_form_editor_field_reset: function(previewMode, widgetValue, params) { // var self = this; // var target_field_name = this.$target.find('.col-form-label').attr('for'); // this.fields().then(function(fields){ @@ -476,9 +476,9 @@ odoo.define('website_form_editor', function (require) { /** * @see this.selectClass for parameters */ - website_form_choice_field_display: function (previewMode, value, $opt) { - this.$target.toggleClass('o_website_form_flex_fw', value === 'vertical'); - this.$target[0].dataset.display = value; + website_form_choice_field_display: function (previewMode, widgetValue, params) { + this.$target.toggleClass('o_website_form_flex_fw', widgetValue === 'vertical'); + this.$target[0].dataset.display = widgetValue; }, //---------------------------------------------------------------------- @@ -488,12 +488,11 @@ odoo.define('website_form_editor', function (require) { /** * @override */ - _setActive: function () { - this._super(...arguments); - this.$el.find('[data-website_form_choice_field_display]') - .removeClass('active') - .filter('[data-website_form_choice_field_display=' + this.$target.attr('data-display') + ']') - .addClass('active'); + _computeWidgetState: function (methodName, params) { + if (methodName === 'website_form_choice_field_display') { + return this.$target.attr('data-display'); + } + return this._super(...arguments); }, }); @@ -502,7 +501,7 @@ odoo.define('website_form_editor', function (require) { xmlDependencies: ['/website_form/static/src/xml/website_form_editor.xml'], // Option to toggle inputs required attribute - website_form_field_require: function (previewMode, value, $li) { + website_form_field_require: function (previewMode, widgetValue, params) { this.$target.find('.o_website_form_input').each(function (index, input) { input.required = !input.required; }); diff --git a/addons/website_form/static/tests/tours/website_form_editor.js b/addons/website_form/static/tests/tours/website_form_editor.js index 4d28b45ab1b..ceb5f9a4efb 100644 --- a/addons/website_form/static/tests/tours/website_form_editor.js +++ b/addons/website_form/static/tests/tours/website_form_editor.js @@ -115,7 +115,7 @@ odoo.define('website_form_editor.tour', function(require) { }, { content: "Click on Hidden", - trigger: "[data-toggle-class='o_website_form_field_hidden']" + trigger: "[data-select-class='o_website_form_field_hidden']" }, { content: "Check the resulting field", diff --git a/addons/website_form/views/snippets.xml b/addons/website_form/views/snippets.xml index e558f6c5eab..0b4ef73b623 100644 --- a/addons/website_form/views/snippets.xml +++ b/addons/website_form/views/snippets.xml @@ -51,7 +51,7 @@ <!-- Field --> <div data-js='website_form_editor' data-selector=".form-field" data-drop-near=".form-field"> <we-checkbox string="Hidden" data-no-preview="true" - data-toggle-class="o_website_form_field_hidden"/> + data-select-class="o_website_form_field_hidden"/> </div> <!-- Add an option to display radio buttons / checkboxes horizontally or vertically --> @@ -72,7 +72,7 @@ <div data-js='website_form_editor_field' data-selector=".form-field:not(.o_website_form_required)"> <we-checkbox string="Required" data-no-preview="true" data-website_form_field_require="" - data-toggle-class="o_website_form_required_custom"/> + data-select-class="o_website_form_required_custom"/> </div> <!-- Remove the duplicate options of model fields --> 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 4f35b1bf00e..bbcca2baf74 100644 --- a/addons/website_sale/static/src/js/website_sale.editor.js +++ b/addons/website_sale/static/src/js/website_sale.editor.js @@ -113,8 +113,8 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({ /** * @see this.selectClass for params */ - setPpg: function (previewMode, value, $opt) { - const ppg = parseInt($opt.find('input').val()); + setPpg: function (previewMode, widgetValue, params) { + const ppg = parseInt(widgetValue); if (!ppg || ppg < 1) { return false; } @@ -129,12 +129,12 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({ /** * @see this.selectClass for params */ - setPpr: function (previewMode, value, $opt) { - this.ppr = parseInt(value); + setPpr: function (previewMode, widgetValue, params) { + this.ppr = parseInt(widgetValue); this._rpc({ route: '/shop/change_ppr', params: { - 'ppr': value, + 'ppr': this.ppr, }, }).then(reload); }, @@ -146,18 +146,16 @@ options.registry.WebsiteSaleGridLayout = options.Class.extend({ /** * @override */ - _setActive: function () { - this._super(...arguments); - - this.$el.find('[data-set-ppr]') - .removeClass('active') - .filter((i, el) => { - var nbColumns = parseInt($(el).data('setPpr')); - return nbColumns === this.ppr; - }) - .addClass('active'); - - this.$el.find('[data-set-ppg] > input').val(this.ppg); + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'setPpg': { + return this.ppg; + } + case 'setPpr': { + return this.ppr; + } + } + return this._super(...arguments); }, }); @@ -172,29 +170,11 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ /** * @override */ - start: function () { + willStart: function () { this.ppr = this.$target.closest('[data-ppr]').data('ppr'); this.productTemplateID = parseInt(this.$target.find('[data-oe-model="product.template"]').data('oe-id')); - var defs = [this._super.apply(this, arguments)]; - - defs.push(this._rpc({ - model: 'product.style', - method: 'search_read', - }).then(data => { - var $menu = this.$el.find('[name="style"]'); - for (var k in data) { - $menu.append(this._buildCheckboxElement(data[k]['name'], { - dataAttributes: { - 'style': data[k]['id'], - 'toggleClass': data[k]['html_class'], - }, - })); - } - this._updateUI(); - })); - - return $.when.apply($, defs); + return this._super(...arguments); }, /** * @override @@ -212,24 +192,24 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ /** * @see this.selectClass for params */ - style: function (previewMode, value, $opt) { + style: function (previewMode, widgetValue, params) { this._rpc({ route: '/shop/change_styles', params: { 'id': this.productTemplateID, - 'style_id': value, + 'style_id': params.possibleValues[params.possibleValues.length - 1], }, }); }, /** * @see this.selectClass for params */ - changeSequence: function (previewMode, value, $opt) { + changeSequence: function (previewMode, widgetValue, params) { this._rpc({ route: '/shop/change_sequence', params: { id: this.productTemplateID, - sequence: value, + sequence: widgetValue, }, }).then(reload); }, @@ -241,8 +221,34 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ /** * @override */ - _updateUI: function () { - this._super.apply(this, arguments); + _renderCustomWidgets: async function (uiFragment) { + const checkboxes = []; + return this._rpc({ + model: 'product.style', + method: 'search_read', + }).then(async data => { + for (var k in data) { + const checkboxWidget = this._registerUserValueWidget('we-checkbox', this, data[k]['name'], { + dataAttributes: { + 'style': data[k]['id'], + 'selectClass': data[k]['html_class'], + }, + }); + checkboxes.push(checkboxWidget); + await checkboxWidget.appendTo(document.createDocumentFragment()); + } + }).then(() => { + const menuEl = uiFragment.querySelector('[name="style"]'); + for (const checkbox of checkboxes) { + menuEl.appendChild(checkbox.el); + } + }); + }, + /** + * @override + */ + _updateUI: async function () { + await this._super.apply(this, arguments); var sizeX = parseInt(this.$target.attr('colspan') || 1); var sizeY = parseInt(this.$target.attr('rowspan') || 1); @@ -277,8 +283,8 @@ options.registry.WebsiteSaleProductsItem = options.Class.extend({ _onTableItemMouseEnter: function (ev) { var $td = $(ev.currentTarget); var $table = $td.closest("table"); - var x = $td.index()+1; - var y = $td.parent().index()+1; + var x = $td.index() + 1; + var y = $td.parent().index() + 1; var tr = []; for (var yi = 0; yi < y; yi++) { @@ -334,7 +340,7 @@ options.registry.ProductsSearchBar = options.Class.extend({ /** * @see this.selectClass for parameters */ - openSearchbarSettings: function (previewMode, value, $opt) { + openSearchbarSettings: function (previewMode, widgetValue, params) { var self = this; new Dialog(this, { title: _t("Products Search Bar"), diff --git a/addons/website_sale/views/snippets.xml b/addons/website_sale/views/snippets.xml index 0d6303a2a39..57034d96e89 100644 --- a/addons/website_sale/views/snippets.xml +++ b/addons/website_sale/views/snippets.xml @@ -60,7 +60,7 @@ <div data-js="WebsiteSaleGridLayout" data-selector="#products_grid .o_wsale_products_grid_table_wrapper > table" data-no-check="true"> - <we-input string="Number of products" data-set-ppg="" data-unit="" data-no-preview="true"/> + <we-input string="Number of products" data-set-ppg="" data-no-preview="true"/> <we-select string="Number of Columns" class="o_wsale_ppr_submenu" data-no-preview="true"> <we-button data-set-ppr="2">2</we-button> <we-button data-set-ppr="3">3</we-button> |
