summaryrefslogtreecommitdiff
path: root/addons/web_editor/static/src/js
diff options
context:
space:
mode:
authorqsm-odoo <qsm@odoo.com>2019-11-18 16:56:42 +0000
committerqsm-odoo <qsm@odoo.com>2019-12-01 15:49:57 +0000
commitb38253f400596dc6c96c2aface43684a56b24d26 (patch)
treec1bee14687b795a3db8a192118b1ef145ba11c5a /addons/web_editor/static/src/js
parent75ab52af9c067b4cbc5a67083e8d02736e11bc3c (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/js')
-rw-r--r--addons/web_editor/static/src/js/common/utils.js144
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.editor.js48
-rw-r--r--addons/web_editor/static/src/js/editor/snippets.options.js1995
3 files changed, 1404 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,
};
});