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 /addons/web_editor/static/src | |
| 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
Diffstat (limited to 'addons/web_editor/static/src')
4 files changed, 1412 insertions, 783 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; |
