odoo.define('web_editor.snippets.options', function (require) { 'use strict'; var core = require('web.core'); const {ColorpickerWidget} = require('web.Colorpicker'); const Dialog = require('web.Dialog'); const rpc = require('web.rpc'); const time = require('web.time'); var Widget = require('web.Widget'); var ColorPaletteWidget = require('web_editor.ColorPalette').ColorPaletteWidget; const weUtils = require('web_editor.utils'); const { normalizeColor, getBgImageURL, } = weUtils; var weWidgets = require('wysiwyg.widgets'); const { loadImage, loadImageInfo, applyModifications, removeOnImageChangeAttrs, } = require('web_editor.image_processing'); var qweb = core.qweb; var _t = core._t; /** * @param {HTMLElement} el * @param {string} [title] * @param {Object} [options] * @param {string[]} [options.classes] * @param {string} [options.tooltip] * @param {string} [options.placeholder] * @param {Object} [options.dataAttributes] * @returns {HTMLElement} - the original 'el' argument */ function _addTitleAndAllowedAttributes(el, title, options) { let tooltipEl = el; if (title) { const titleEl = _buildTitleElement(title); tooltipEl = titleEl; el.appendChild(titleEl); } if (options && options.classes) { el.classList.add(...options.classes); } if (options && options.tooltip) { tooltipEl.title = options.tooltip; } if (options && options.placeholder) { el.setAttribute('placeholder', options.placeholder); } if (options && options.dataAttributes) { for (const key in options.dataAttributes) { el.dataset[key] = options.dataAttributes[key]; } } return el; } /** * @param {string} tagName * @param {string} title - @see _addTitleAndAllowedAttributes * @param {Object} options - @see _addTitleAndAllowedAttributes * @returns {HTMLElement} */ function _buildElement(tagName, title, options) { const el = document.createElement(tagName); return _addTitleAndAllowedAttributes(el, title, options); } /** * @param {string} title * @returns {HTMLElement} */ function _buildTitleElement(title) { const titleEl = document.createElement('we-title'); titleEl.textContent = title; return titleEl; } /** * @param {string} src * @returns {HTMLElement} */ const _buildImgElementCache = {}; async function _buildImgElement(src) { if (!(src in _buildImgElementCache)) { _buildImgElementCache[src] = (async () => { if (src.split('.').pop() === 'svg') { const response = await window.fetch(src); const text = await response.text(); const parser = new window.DOMParser(); const xmlDoc = parser.parseFromString(text, 'text/xml'); return xmlDoc.getElementsByTagName('svg')[0]; } else { const imgEl = document.createElement('img'); imgEl.src = src; return imgEl; } })(); } const node = await _buildImgElementCache[src]; return node.cloneNode(true); } /** * Build the correct DOM for a we-row element. * * @param {string} [title] - @see _buildElement * @param {Object} [options] - @see _buildElement * @param {HTMLElement[]} [options.childNodes] * @returns {HTMLElement} */ function _buildRowElement(title, options) { const groupEl = _buildElement('we-row', title, options); const rowEl = document.createElement('div'); groupEl.appendChild(rowEl); if (options && options.childNodes) { options.childNodes.forEach(node => rowEl.appendChild(node)); } return groupEl; } /** * Build the correct DOM for a we-collapse element. * * @param {string} [title] - @see _buildElement * @param {Object} [options] - @see _buildElement * @param {HTMLElement[]} [options.childNodes] * @returns {HTMLElement} */ function _buildCollapseElement(title, options) { const groupEl = _buildElement('we-collapse', title, options); const titleEl = groupEl.querySelector('we-title'); const children = options && options.childNodes || []; if (titleEl) { titleEl.remove(); children.unshift(titleEl); } let i = 0; for (i = 0; i < children.length; i++) { groupEl.appendChild(children[i]); if (children[i].nodeType === Node.ELEMENT_NODE) { break; } } const togglerEl = document.createElement('we-toggler'); togglerEl.classList.add('o_we_collapse_toggler'); groupEl.appendChild(togglerEl); const containerEl = document.createElement('div'); children.slice(i + 1).forEach(node => containerEl.appendChild(node)); groupEl.appendChild(containerEl); return groupEl; } /** * Creates a proxy for an object where one property is replaced by a different * value. This value is captured in the closure and can be read and written to. * * @param {Object} obj - the object for which to create a proxy * @param {string} propertyName - the name/key of the property to replace * @param {*} value - the initial value to give to the property's copy * @returns {Proxy} a proxy of the object with the property replaced */ function createPropertyProxy(obj, propertyName, value) { return new Proxy(obj, { get: function (obj, prop) { if (prop === propertyName) { return value; } return obj[prop]; }, set: function (obj, prop, val) { if (prop === propertyName) { return (value = val); } return Reflect.set(...arguments); }, }); } //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: const NULL_ID = '__NULL__'; /** * Base class for components to be used in snippet options widgets to retrieve * user values. */ const UserValueWidget = Widget.extend({ className: 'o_we_user_value_widget', custom_events: { 'user_value_update': '_onUserValueNotification', }, /** * @constructor */ init: function (parent, title, options, $target) { this._super(...arguments); this.title = title; this.options = options; this._userValueWidgets = []; this._value = ''; this.$target = $target; }, /** * @override */ async willStart() { await this._super(...arguments); if (this.options.dataAttributes.img) { this.imgEl = await _buildImgElement(this.options.dataAttributes.img); } }, /** * @override */ _makeDescriptive: function () { const $el = this._super(...arguments); const el = $el[0]; _addTitleAndAllowedAttributes(el, this.title, this.options); this.containerEl = document.createElement('div'); if (this.imgEl) { this.containerEl.appendChild(this.imgEl); } el.appendChild(this.containerEl); return $el; }, /** * @override */ async start() { await this._super(...arguments); if (this.el.classList.contains('o_we_img_animate')) { const buildImgExtensionSwitcher = (from, to) => { const regex = new RegExp(`${from}$`, 'i'); return ev => { const img = ev.currentTarget.getElementsByTagName("img")[0]; img.src = img.src.replace(regex, to); }; }; this.$el.on('mouseenter.img_animate', buildImgExtensionSwitcher('png', 'gif')); this.$el.on('mouseleave.img_animate', buildImgExtensionSwitcher('gif', 'png')); } }, /** * @override */ destroy() { // Check if $el exists in case the widget is destroyed before it has // been fully initialized. // TODO there is probably better to do. This case was found only in // tours, where the editor is left before the widget icon is loaded. if (this.$el) { this.$el.off('.img_animate'); } this._super(...arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Closes the widget (only meaningful for widgets that can be closed). */ close: function () { if (!this.el) { // In case the method is called while the widget is not fully // initialized yet. No need to prevent that case: asking a non // initialized widget to close itself should just not be a problem // and just be ignored. return; } this.trigger_up('user_value_widget_closing'); this.el.classList.remove('o_we_widget_opened'); this._userValueWidgets.forEach(widget => widget.close()); }, /** * Simulates the correct event on the element to make it active. */ enable() { this.$el.click(); }, /** * @param {string} name * @returns {UserValueWidget|null} */ findWidget: function (name) { for (const widget of this._userValueWidgets) { if (widget.getName() === name) { return widget; } const depWidget = widget.findWidget(name); if (depWidget) { return depWidget; } } return null; }, /** * Returns the value that the widget would hold if it was active, by default * the internal value it holds. * * @param {string} [methodName] * @returns {string} */ getActiveValue: function (methodName) { return this._value; }, /** * Returns the default value the widget holds when inactive, by default the * first "possible value". * * @param {string} [methodName] * @returns {string} */ getDefaultValue: function (methodName) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; return possibleValues && possibleValues[0] || ''; }, /** * @returns {string[]} */ getDependencies: function () { return this._dependencies; }, /** * Returns the names of the option methods associated to the widget. Those * are loaded with @see loadMethodsData. * * @returns {string[]} */ getMethodsNames: function () { return this._methodsNames; }, /** * Returns the option parameters associated to the widget (for a given * method name or not). Most are loaded with @see loadMethodsData. * * @param {string} [methodName] * @returns {Object} */ getMethodsParams: function (methodName) { const params = _.extend({}, this._methodsParams); if (methodName) { params.possibleValues = params.optionsPossibleValues[methodName] || []; params.activeValue = this.getActiveValue(methodName); params.defaultValue = this.getDefaultValue(methodName); } return params; }, /** * @returns {string} empty string if no name is used by the widget */ getName: function () { return this._methodsParams.name || ''; }, /** * Returns the user value that the widget currently holds. The value is a * string, this is the value that will be received in the option methods * of SnippetOptionWidget instances. * * @param {string} [methodName] * @returns {string} */ getValue: function (methodName) { const isActive = this.isActive(); if (!methodName || !this._methodsNames.includes(methodName)) { return isActive ? 'true' : ''; } if (isActive) { return this.getActiveValue(methodName); } return this.getDefaultValue(methodName); }, /** * Returns whether or not the widget is active (holds a value). * * @returns {boolean} */ isActive: function () { return this._value && this._value !== NULL_ID; }, /** * Indicates if the widget can contain sub user value widgets or not. * * @returns {boolean} */ isContainer: function () { return false; }, /** * Indicates if the widget is being previewed or not: the user is * manipulating it. Base case: if an internal element is focused. * * @returns {boolean} */ isPreviewed: function () { const focusEl = document.activeElement; if (focusEl && focusEl.tagName === 'INPUT' && (this.el === focusEl || this.el.contains(focusEl))) { return true; } return this.el.classList.contains('o_we_preview'); }, /** * Loads option method names and option method parameters. * * @param {string[]} validMethodNames * @param {Object} extraParams */ loadMethodsData: function (validMethodNames, extraParams) { this._methodsNames = []; this._methodsParams = _.extend({}, extraParams); this._methodsParams.optionsPossibleValues = {}; this._dependencies = []; this._triggerWidgetsNames = []; this._triggerWidgetsValues = []; for (const key in this.el.dataset) { const dataValue = this.el.dataset[key].trim(); if (key === 'dependencies') { this._dependencies.push(...dataValue.split(/\s*,\s*/g)); } else if (key === 'trigger') { this._triggerWidgetsNames.push(...dataValue.split(/\s*,\s*/g)); } else if (key === 'triggerValue') { this._triggerWidgetsValues.push(...dataValue.split(/\s*,\s*/g)); } else if (validMethodNames.includes(key)) { this._methodsNames.push(key); this._methodsParams.optionsPossibleValues[key] = dataValue.split(/\s*\|\s*/g); } else { this._methodsParams[key] = dataValue; } } this._userValueWidgets.forEach(widget => { const inheritedParams = _.extend({}, this._methodsParams); inheritedParams.optionsPossibleValues = null; widget.loadMethodsData(validMethodNames, inheritedParams); const subMethodsNames = widget.getMethodsNames(); const subMethodsParams = widget.getMethodsParams(); for (const methodName of subMethodsNames) { if (!this._methodsNames.includes(methodName)) { this._methodsNames.push(methodName); this._methodsParams.optionsPossibleValues[methodName] = []; } for (const subPossibleValue of subMethodsParams.optionsPossibleValues[methodName]) { this._methodsParams.optionsPossibleValues[methodName].push(subPossibleValue); } } }); for (const methodName of this._methodsNames) { const arr = this._methodsParams.optionsPossibleValues[methodName]; const uniqArr = arr.filter((v, i, arr) => i === arr.indexOf(v)); this._methodsParams.optionsPossibleValues[methodName] = uniqArr; } }, /** * @param {boolean} [previewMode=false] * @param {boolean} [isSimulatedEvent=false] */ notifyValueChange: function (previewMode, isSimulatedEvent) { // If the widget has no associated method, it should not notify user // value changes if (!this._methodsNames.length) { return; } // In the case we notify a change update, force a preview update if it // was not already previewed const isPreviewed = this.isPreviewed(); if (!previewMode && !isPreviewed) { this.notifyValueChange(true); } const data = { previewMode: previewMode || false, isSimulatedEvent: !!isSimulatedEvent, }; // TODO improve this. The preview state has to be updated only when the // actual option _select is gonna be called... but this is delayed by a // mutex. So, during test tours, we would notify both 'preview' and // 'reset' before the 'preview' handling is done: and so the widget // would not be considered in preview during that 'preview' handling. if (previewMode === true || previewMode === false) { // Note: the widgets need to be considered in preview mode during // non-preview handling (a previewed checkbox is considered having // an inverted state)... but if, for example, a modal opens before // handling that non-preview, a 'reset' will be thrown thus removing // the preview class. So we force it in non-preview too. data.prepare = () => this.el.classList.add('o_we_preview'); } else if (previewMode === 'reset') { data.prepare = () => this.el.classList.remove('o_we_preview'); } this.trigger_up('user_value_update', data); }, /** * Opens the widget (only meaningful for widgets that can be opened). */ open() { this.trigger_up('user_value_widget_opening'); this.el.classList.add('o_we_widget_opened'); }, /** * Adds the given widget to the known list of user value sub-widgets (useful * for container widgets). * * @param {UserValueWidget} widget */ registerSubWidget: function (widget) { this._userValueWidgets.push(widget); }, /** * Sets the user value that the widget should currently hold, for the * given method name. * * Note: a widget typically only holds one value for the only method it * supports. However, widgets can have several methods; in that case, the * value is typically received for a first method and receiving the value * for other ones should not affect the widget (otherwise, it means the * methods are conflicting with each other). * * @param {string} value * @param {string} [methodName] */ async setValue(value, methodName) { this._value = value; this.el.classList.remove('o_we_preview'); }, /** * @param {boolean} show */ toggleVisibility: function (show) { this.el.classList.toggle('d-none', !show); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private * @param {OdooEvent|Event} * @returns {boolean} */ _handleNotifierEvent: function (ev) { if (!ev) { return true; } if (ev._seen) { return false; } ev._seen = true; if (ev.preventDefault) { ev.preventDefault(); } return true; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Should be called when an user event on the widget indicates a value * change. * * @private * @param {OdooEvent|Event} [ev] */ _onUserValueChange: function (ev) { if (this._handleNotifierEvent(ev)) { this.notifyValueChange(false); } }, /** * Allows container widgets to add additional data if needed. * * @private * @param {OdooEvent} ev */ _onUserValueNotification: function (ev) { ev.data.widget = this; if (!ev.data.triggerWidgetsNames) { ev.data.triggerWidgetsNames = []; } ev.data.triggerWidgetsNames.push(...this._triggerWidgetsNames); if (!ev.data.triggerWidgetsValues) { ev.data.triggerWidgetsValues = []; } ev.data.triggerWidgetsValues.push(...this._triggerWidgetsValues); }, /** * Should be called when an user event on the widget indicates a value * preview. * * @private * @param {OdooEvent|Event} [ev] */ _onUserValuePreview: function (ev) { if (this._handleNotifierEvent(ev)) { this.notifyValueChange(true); } }, /** * Should be called when an user event on the widget indicates a value * reset. * * @private * @param {OdooEvent|Event} [ev] */ _onUserValueReset: function (ev) { if (this._handleNotifierEvent(ev)) { this.notifyValueChange('reset'); } }, }); const ButtonUserValueWidget = UserValueWidget.extend({ tagName: 'we-button', events: { 'click': '_onButtonClick', 'click [role="button"]': '_onInnerButtonClick', 'mouseenter': '_onUserValuePreview', 'mouseleave': '_onUserValueReset', }, /** * @override */ start: function (parent, title, options) { if (this.options && this.options.childNodes) { this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); } return this._super(...arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ getActiveValue: function (methodName) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; return possibleValues && possibleValues[possibleValues.length - 1] || ''; }, /** * @override */ isActive: function () { return (this.isPreviewed() !== this.el.classList.contains('active')); }, /** * @override */ loadMethodsData: function (validMethodNames) { this._super.apply(this, arguments); for (const methodName of this._methodsNames) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; if (possibleValues.length <= 1) { possibleValues.unshift(''); } } }, /** * @override */ async setValue(value, methodName) { await this._super(...arguments); let active = !!value; if (methodName) { if (!this._methodsNames.includes(methodName)) { return; } active = (this.getActiveValue(methodName) === value); } this.el.classList.toggle('active', active); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private */ _onButtonClick: function (ev) { if (!ev._innerButtonClicked) { this._onUserValueChange(ev); } }, /** * @private */ _onInnerButtonClick: function (ev) { // Cannot just stop propagation as the click needs to be propagated to // potential parent widgets for event delegation on those inner buttons. ev._innerButtonClicked = true; }, }); const CheckboxUserValueWidget = ButtonUserValueWidget.extend({ className: (ButtonUserValueWidget.prototype.className || '') + ' o_we_checkbox_wrapper', /** * @override */ start: function () { const checkboxEl = document.createElement('we-checkbox'); this.containerEl.appendChild(checkboxEl); return this._super(...arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ enable() { this.$('we-checkbox').click(); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @override */ _onButtonClick(ev) { if (!ev.target.closest('we-title, we-checkbox')) { // Only consider clicks on the label and the checkbox control itself return; } return this._super(...arguments); }, }); const BaseSelectionUserValueWidget = UserValueWidget.extend({ /** * @override */ async start() { await this._super(...arguments); this.menuEl = document.createElement('we-selection-items'); if (this.options && this.options.childNodes) { this.options.childNodes.forEach(node => this.menuEl.appendChild(node)); } this.containerEl.appendChild(this.menuEl); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ getMethodsParams(methodName) { const params = this._super(...arguments); const activeWidget = this._getActiveSubWidget(); if (!activeWidget) { return params; } return Object.assign(activeWidget.getMethodsParams(...arguments), params); }, /** * @override */ getValue(methodName) { const activeWidget = this._getActiveSubWidget(); if (activeWidget) { return activeWidget.getActiveValue(methodName); } return this._super(...arguments); }, /** * @override */ isContainer() { return true; }, /** * @override */ async setValue(value, methodName) { const _super = this._super.bind(this); for (const widget of this._userValueWidgets) { await widget.setValue(NULL_ID, methodName); } for (const widget of [...this._userValueWidgets].reverse()) { await widget.setValue(value, methodName); if (widget.isActive()) { // Only one select item can be true at a time, we consider the // last one if multiple would be active. return; } } await _super(...arguments); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private * @returns {UserValueWidget|undefined} */ _getActiveSubWidget() { const previewedWidget = this._userValueWidgets.find(widget => widget.isPreviewed()); if (previewedWidget) { return previewedWidget; } return this._userValueWidgets.find(widget => widget.isActive()); }, }); const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ tagName: 'we-select', events: { 'click': '_onClick', }, /** * @override */ async start() { await this._super(...arguments); if (this.options && this.options.valueEl) { this.containerEl.insertBefore(this.options.valueEl, this.menuEl); } this.menuTogglerEl = document.createElement('we-toggler'); this.icon = this.el.dataset.icon || false; if (this.icon) { this.el.classList.add('o_we_icon_select'); const iconEl = document.createElement('i'); iconEl.classList.add('fa', 'fa-fw', this.icon); this.menuTogglerEl.appendChild(iconEl); } this.containerEl.insertBefore(this.menuTogglerEl, this.menuEl); const dropdownCaretEl = document.createElement('span'); dropdownCaretEl.classList.add('o_we_dropdown_caret'); this.containerEl.appendChild(dropdownCaretEl); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ close: function () { this._super(...arguments); if (this.menuTogglerEl) { this.menuTogglerEl.classList.remove('active'); } }, /** * @override */ isPreviewed: function () { return this._super(...arguments) || this.menuTogglerEl.classList.contains('active'); }, /** * @override */ open() { this._super(...arguments); this.menuTogglerEl.classList.add('active'); }, /** * @override */ async setValue() { await this._super(...arguments); if (this.icon) { return; } if (this.menuTogglerItemEl) { this.menuTogglerItemEl.remove(); this.menuTogglerItemEl = null; } let textContent = ''; const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); if (activeWidget) { const svgTag = activeWidget.el.querySelector('svg'); // useful to avoid searching text content in svg element const value = (activeWidget.el.dataset.selectLabel || (!svgTag && activeWidget.el.textContent.trim())); const imgSrc = activeWidget.el.dataset.img; if (value) { textContent = value; } else if (imgSrc) { this.menuTogglerItemEl = document.createElement('img'); this.menuTogglerItemEl.src = imgSrc; } else { const fakeImgEl = activeWidget.el.querySelector('.o_we_fake_img_item'); if (fakeImgEl) { this.menuTogglerItemEl = fakeImgEl.cloneNode(true); } } } else { textContent = "/"; } this.menuTogglerEl.textContent = textContent; if (this.menuTogglerItemEl) { this.menuTogglerEl.appendChild(this.menuTogglerItemEl); } }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private * @param {Event} ev */ _shouldIgnoreClick(ev) { return !!ev.target.closest('[role="button"]'); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Called when the select is clicked anywhere -> open/close it. * * @private */ _onClick: function (ev) { if (this._shouldIgnoreClick(ev)) { return; } if (!this.menuTogglerEl.classList.contains('active')) { this.open(); } else { this.close(); } const activeButton = this._userValueWidgets.find(widget => widget.isActive()); if (activeButton) { this.menuEl.scrollTop = activeButton.el.offsetTop - (this.menuEl.offsetHeight / 2); } }, }); const ButtonGroupUserValueWidget = BaseSelectionUserValueWidget.extend({ tagName: 'we-button-group', }); const UnitUserValueWidget = UserValueWidget.extend({ /** * @override */ start: async function () { const unit = this.el.dataset.unit || ''; this.el.dataset.unit = unit; if (this.el.dataset.saveUnit === undefined) { this.el.dataset.saveUnit = unit; } return this._super(...arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ getActiveValue: function (methodName) { const activeValue = this._super(...arguments); const params = this._methodsParams; if (!params.unit) { return activeValue; } const defaultValue = this.getDefaultValue(methodName, false); return activeValue.split(/\s+/g).map(v => { const numValue = parseFloat(v); if (isNaN(numValue)) { return defaultValue; } else { const value = weUtils.convertNumericToUnit(numValue, params.unit, params.saveUnit, params.cssProperty, this.$target); return `${this._floatToStr(value)}${params.saveUnit}`; } }).join(' '); }, /** * @override * @param {boolean} [useInputUnit=false] */ getDefaultValue: function (methodName, useInputUnit) { const defaultValue = this._super(...arguments); const params = this._methodsParams; if (!params.unit) { return defaultValue; } const unit = useInputUnit ? params.unit : params.saveUnit; const numValue = weUtils.convertValueToUnit(defaultValue || '0', unit, params.cssProperty, this.$target); if (isNaN(numValue)) { return defaultValue; } return `${this._floatToStr(numValue)}${unit}`; }, /** * @override */ isActive: function () { const isSuperActive = this._super(...arguments); const params = this._methodsParams; if (!params.unit) { return isSuperActive; } return isSuperActive && this._floatToStr(parseFloat(this._value)) !== '0'; }, /** * @override */ async setValue(value, methodName) { const params = this._methodsParams; if (params.unit) { value = value.split(' ').map(v => { const numValue = weUtils.convertValueToUnit(v, params.unit, params.cssProperty, this.$target); if (isNaN(numValue)) { return ''; // Something not supported } return this._floatToStr(numValue); }).join(' '); } return this._super(value, methodName); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Converts a floating value to a string, rounded to 5 digits without zeros. * * @private * @param {number} value * @returns {string} */ _floatToStr: function (value) { return `${parseFloat(value.toFixed(5))}`; }, }); const InputUserValueWidget = UnitUserValueWidget.extend({ tagName: 'we-input', events: { 'input input': '_onInputInput', 'blur input': '_onInputBlur', 'keydown input': '_onInputKeydown', }, /** * @override */ start: async function () { await this._super(...arguments); const unit = this.el.dataset.unit; this.inputEl = document.createElement('input'); this.inputEl.setAttribute('type', 'text'); this.inputEl.setAttribute('autocomplete', 'chrome-off'); this.inputEl.setAttribute('placeholder', this.el.getAttribute('placeholder') || ''); this.inputEl.classList.toggle('text-left', !unit); this.inputEl.classList.toggle('text-right', !!unit); this.containerEl.appendChild(this.inputEl); var unitEl = document.createElement('span'); unitEl.textContent = unit; this.containerEl.appendChild(unitEl); if (unit.length > 3) { this.el.classList.add('o_we_large_input'); } }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ async setValue() { await this._super(...arguments); this.inputEl.value = this._value; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private * @param {Event} ev */ _onInputInput: function (ev) { this._value = this.inputEl.value; this._onUserValuePreview(ev); }, /** * @private * @param {Event} ev */ _onInputBlur: function (ev) { // Sometimes, an input is focusout for internal reason (like an undo // recording) then focused again manually in the same JS stack // execution. In that case, the blur should not trigger an option // selection as the user did not leave the input. We thus defer the blur // handling to then check that the target is indeed still blurred before // executing the actual option selection. setTimeout(() => { if (ev.currentTarget === document.activeElement) { return; } this._onUserValueChange(ev); }); }, /** * @private * @param {Event} ev */ _onInputKeydown: function (ev) { switch (ev.which) { case $.ui.keyCode.ENTER: { this._onUserValueChange(ev); break; } case $.ui.keyCode.UP: case $.ui.keyCode.DOWN: { const input = ev.currentTarget; const params = this._methodsParams; if (!params.unit && !params.step) { break; } let value = parseFloat(input.value || input.placeholder); if (isNaN(value)) { value = 0.0; } let step = parseFloat(params.step); if (isNaN(step)) { step = 1.0; } value += (ev.which === $.ui.keyCode.UP ? step : -step); input.value = this._floatToStr(value); $(input).trigger('input'); break; } } }, }); const MultiUserValueWidget = UserValueWidget.extend({ tagName: 'we-multi', /** * @override */ start: function () { if (this.options && this.options.childNodes) { this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); } return this._super(...arguments); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ getValue: function (methodName) { const value = this._userValueWidgets.map(widget => { return widget.getValue(methodName); }).join(' ').trim(); return value || this._super(...arguments); }, /** * @override */ isContainer: function () { return true; }, /** * @override */ async setValue(value, methodName) { let values = value.split(/\s*\|\s*/g); if (values.length === 1) { values = value.split(/\s+/g); } for (let i = 0; i < this._userValueWidgets.length - 1; i++) { await this._userValueWidgets[i].setValue(values.shift() || '', methodName); } await this._userValueWidgets[this._userValueWidgets.length - 1].setValue(values.join(' '), methodName); }, }); const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ className: (SelectUserValueWidget.prototype.className || '') + ' o_we_so_color_palette', custom_events: _.extend({}, SelectUserValueWidget.prototype.custom_events, { 'custom_color_picked': '_onCustomColorPicked', 'color_picked': '_onColorPicked', 'color_hover': '_onColorHovered', 'color_leave': '_onColorLeft', 'enter_key_color_colorpicker': '_onEnterKey' }), /** * @override */ start: async function () { const _super = this._super.bind(this); const args = arguments; if (this.options.dataAttributes.lazyPalette === 'true') { // TODO review in master, this was done in stable to keep the speed // fix as stable as possible (to have a reference to a widget even // if not a colorPalette widget). this.colorPalette = new Widget(this); this.colorPalette.getColorNames = () => []; await this.colorPalette.appendTo(document.createDocumentFragment()); } else { await this._renderColorPalette(); } // Build the select element with a custom span to hold the color preview this.colorPreviewEl = document.createElement('span'); this.colorPreviewEl.classList.add('o_we_color_preview'); this.options.childNodes = [this.colorPalette.el]; this.options.valueEl = this.colorPreviewEl; return _super(...args); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ open: function () { if (this.colorPalette.setSelectedColor) { this.colorPalette.setSelectedColor(this._value); } else { // TODO review in master, this does async stuff. Maybe the open // method should now be async. This is not really robust as the // colorPalette can be used without it to be fully rendered but // the use of the saved promise where we can should mitigate that // issue. this._colorPaletteRenderPromise = this._renderColorPalette(); } this._super(...arguments); }, /** * @override */ close: function () { this._super(...arguments); if (this._customColorValue && this._customColorValue !== this._value) { this._value = this._customColorValue; this._customColorValue = false; this._onUserValueChange(); } }, /** * @override */ getMethodsParams: function () { return _.extend(this._super(...arguments), { colorNames: this.colorPalette.getColorNames(), }); }, /** * @override */ getValue: function (methodName) { if (typeof this._previewColor === 'string') { return this._previewColor; } if (typeof this._customColorValue === 'string') { return this._customColorValue; } let value = this._super(...arguments); if (value) { const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor'); const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible'); if ((useCssColor || cssCompatible) && !ColorpickerWidget.isCSSColor(value)) { if (useCssColor) { value = weUtils.getCSSVariableValue(value); } else { value = `var(--${value})`; } } } return value; }, /** * @override */ isContainer: function () { return false; }, /** * @override */ isActive: function () { return !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); }, /** * Updates the color preview + re-render the whole color palette widget. * * @override */ async setValue(color) { await this._super(...arguments); await this._colorPaletteRenderPromise; const classes = weUtils.computeColorClasses(this.colorPalette.getColorNames()); this.colorPreviewEl.classList.remove(...classes); this.colorPreviewEl.style.removeProperty('background-color'); if (this._value) { if (ColorpickerWidget.isCSSColor(this._value)) { this.colorPreviewEl.style.backgroundColor = this._value; } else if (weUtils.isColorCombinationName(this._value)) { this.colorPreviewEl.classList.add('o_cc', `o_cc${this._value}`); } else { this.colorPreviewEl.classList.add(`bg-${this._value}`); } } }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private * @returns {Promise} */ _renderColorPalette: function () { const options = { selectedColor: this._value, }; if (this.options.dataAttributes.excluded) { options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(','); } if (this.options.dataAttributes.withCombinations) { options.withCombinations = !!this.options.dataAttributes.withCombinations; } const oldColorPalette = this.colorPalette; this.colorPalette = new ColorPaletteWidget(this, options); if (oldColorPalette) { return this.colorPalette.insertAfter(oldColorPalette.el).then(() => { oldColorPalette.destroy(); }); } return this.colorPalette.appendTo(document.createDocumentFragment()); }, /** * @override */ _shouldIgnoreClick(ev) { return ev.originalEvent.__isColorpickerClick || this._super(...arguments); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Called when a custom color is selected -> preview the color * and set the current value. Update of this value on close * * @private * @param {Event} ev */ _onCustomColorPicked: function (ev) { this._customColorValue = ev.data.color; }, /** * Called when a color button is clicked -> confirms the preview. * * @private * @param {Event} ev */ _onColorPicked: function (ev) { this._previewColor = false; this._customColorValue = false; this._value = ev.data.color; this._onUserValueChange(ev); }, /** * Called when a color button is entered -> previews the background color. * * @private * @param {Event} ev */ _onColorHovered: function (ev) { this._previewColor = ev.data.color; this._onUserValuePreview(ev); }, /** * Called when a color button is left -> cancels the preview. * * @private * @param {Event} ev */ _onColorLeft: function (ev) { this._previewColor = false; this._onUserValueReset(ev); }, /** * @private */ _onEnterKey: function () { this.close(); }, }); const MediapickerUserValueWidget = UserValueWidget.extend({ tagName: 'we-button', events: { 'click': '_onEditMedia', }, /** * @override */ async start() { await this._super(...arguments); const iconEl = document.createElement('i'); if (this.options.dataAttributes.buttonStyle) { iconEl.classList.add('fa', 'fa-fw', 'fa-camera'); } else { iconEl.classList.add('fa', 'fa-fw', 'fa-refresh', 'mr-1'); this.el.classList.add('o_we_no_toggle'); this.containerEl.textContent = _t("Replace media"); } $(this.containerEl).prepend(iconEl); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Creates and opens a media dialog to edit a given element's media. * * @private * @param {HTMLElement} el the element whose media should be edited * @param {boolean} [images] whether images should be available * default: false * @param {boolean} [videos] whether videos should be available * default: false */ _openDialog(el, {images = false, videos = false}) { el.src = this._value; const $editable = this.$target.closest('.o_editable'); const mediaDialog = new weWidgets.MediaDialog(this, { noImages: !images, noVideos: !videos, noIcons: true, noDocuments: true, isForBgVideo: true, 'res_model': $editable.data('oe-model'), 'res_id': $editable.data('oe-id'), }, el).open(); return mediaDialog; }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ async setValue() { await this._super(...arguments); this.el.classList.toggle('active', this.isActive()); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Called when the edit button is clicked. * * @private * @param {Event} ev */ _onEditMedia: function (ev) {}, }); const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({ //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @override */ _onEditMedia(ev) { // Need a dummy element for the media dialog to modify. const dummyEl = document.createElement('img'); const dialog = this._openDialog(dummyEl, {images: true}); dialog.on('save', this, data => { // Accessing the value directly through dummyEl.src converts the url to absolute, // using getAttribute allows us to keep the url as it was inserted in the DOM // which can be useful to compare it to values stored in db. this._value = dummyEl.getAttribute('src'); this._onUserValueChange(); }); }, }); const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({ //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @override */ _onEditMedia(ev) { // Need a dummy element for the media dialog to modify. const dummyEl = document.createElement('iframe'); const dialog = this._openDialog(dummyEl, {videos: true}); dialog.on('save', this, data => { this._value = data.bgVideoSrc; this._onUserValueChange(); }); }, }); const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ events: { // Explicitely not consider all InputUserValueWidget events 'blur input': '_onInputBlur', 'change.datetimepicker': '_onDateTimePickerChange', 'error.datetimepicker': '_onDateTimePickerError', }, /** * @override */ init: function () { this._super(...arguments); this._value = moment().unix().toString(); this.__libInput = 0; }, /** * @override */ start: async function () { await this._super(...arguments); const datetimePickerId = _.uniqueId('datetimepicker'); this.el.classList.add('o_we_large_input'); this.inputEl.classList.add('datetimepicker-input', 'mx-0', 'text-left'); this.inputEl.setAttribute('id', datetimePickerId); this.inputEl.setAttribute('data-target', '#' + datetimePickerId); const datepickersOptions = { minDate: moment({ y: 1000 }), maxDate: moment().add(200, 'y'), calendarWeeks: true, defaultDate: moment().format(), icons: { close: 'fa fa-check primary', }, locale: moment.locale(), format: time.getLangDatetimeFormat(), sideBySide: true, buttons: { showClose: true, showToday: true, }, widgetParent: 'body', // Open the datetimepicker on focus not on click. This allows to // take care of a bug which is due to the summernote editor: // sometimes, the datetimepicker loses the focus then get it back // in the same execution flow. This was making the datepicker close // for no apparent reason. Now, it only closes then reopens directly // without it be possible to notice. allowInputToggle: true, }; this.__libInput++; const $input = $(this.inputEl); $input.datetimepicker(datepickersOptions); this.__libInput--; // Monkey-patch the library option to add custom classes on the pickers const libObject = $input.data('datetimepicker'); const oldFunc = libObject._getTemplate; libObject._getTemplate = function () { const $template = oldFunc.call(this, ...arguments); $template.addClass('o_we_no_overlay o_we_datetimepicker'); return $template; }; }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ isPreviewed: function () { return this._super(...arguments) || !!$(this.inputEl).data('datetimepicker').widget; }, /** * @override */ async setValue() { await this._super(...arguments); let momentObj = moment.unix(this._value); if (!momentObj.isValid()) { momentObj = moment(); } this.__libInput++; $(this.inputEl).datetimepicker('date', momentObj); this.__libInput--; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private * @param {Event} ev */ _onDateTimePickerChange: function (ev) { if (this.__libInput > 0) { return; } if (!ev.date || !ev.date.isValid()) { return; } this._value = ev.date.unix().toString(); this._onUserValuePreview(ev); }, /** * Prevents crash manager to throw CORS error. Note that library already * clears the wrong date format. */ _onDateTimePickerError: function (ev) { ev.stopPropagation(); }, }); const RangeUserValueWidget = UnitUserValueWidget.extend({ tagName: 'we-range', events: { 'change input': '_onInputChange', }, /** * @override */ async start() { await this._super(...arguments); this.input = document.createElement('input'); this.input.type = "range"; let min = this.el.dataset.min && parseFloat(this.el.dataset.min) || 0; let max = this.el.dataset.max && parseFloat(this.el.dataset.max) || 100; const step = this.el.dataset.step && parseFloat(this.el.dataset.step) || 1; if (min > max) { [min, max] = [max, min]; this.input.classList.add('o_we_inverted_range'); } this.input.setAttribute('min', min); this.input.setAttribute('max', max); this.input.setAttribute('step', step); this.containerEl.appendChild(this.input); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ async setValue(value, methodName) { await this._super(...arguments); this.input.value = this._value; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private */ _onInputChange(ev) { this._value = ev.target.value; this._onUserValueChange(ev); }, }); const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ className: (SelectUserValueWidget.prototype.className || '') + ' o_we_select_pager', events: Object.assign({}, SelectUserValueWidget.prototype.events, { 'click .o_we_pager_next, .o_we_pager_prev': '_onPageChange', }), /** * @override */ async start() { const _super = this._super.bind(this); this.pages = this.options.childNodes.filter(node => node.matches && node.matches('we-select-page')); this.numPages = this.pages.length; const prev = document.createElement('i'); prev.classList.add('o_we_pager_prev', 'fa', 'fa-chevron-left'); this.pageNum = document.createElement('span'); this.currentPage = 0; const next = document.createElement('i'); next.classList.add('o_we_pager_next', 'fa', 'fa-chevron-right'); const pagerControls = document.createElement('div'); pagerControls.classList.add('o_we_pager_controls'); pagerControls.appendChild(prev); pagerControls.appendChild(this.pageNum); pagerControls.appendChild(next); this.pageName = document.createElement('b'); const pagerHeader = document.createElement('div'); pagerHeader.classList.add('o_we_pager_header'); pagerHeader.appendChild(this.pageName); pagerHeader.appendChild(pagerControls); await _super(...arguments); this.menuEl.classList.add('o_we_has_pager'); $(this.menuEl).prepend(pagerHeader); this._updatePage(); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _shouldIgnoreClick(ev) { return !!ev.target.closest('.o_we_pager_header') || this._super(...arguments); }, /** * Updates the pager's page number display. * * @private */ _updatePage() { this.pages.forEach((page, i) => page.classList.toggle('active', i === this.currentPage)); this.pageNum.textContent = `${this.currentPage + 1}/${this.numPages}`; const activePage = this.pages.find((page, i) => i === this.currentPage); this.pageName.textContent = activePage.getAttribute('string'); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Goes to the previous/next page with wrap-around. * * @private */ _onPageChange(ev) { ev.preventDefault(); ev.stopPropagation(); const delta = ev.target.matches('.o_we_pager_next') ? 1 : -1; this.currentPage = (this.currentPage + this.numPages + delta) % this.numPages; this._updatePage(); }, /** * @override */ _onClick(ev) { const activeButton = this._getActiveSubWidget(); if (activeButton) { const currentPage = this.pages.indexOf(activeButton.el.closest('we-select-page')); if (currentPage !== -1) { this.currentPage = currentPage; this._updatePage(); } } return this._super(...arguments); }, }); const userValueWidgetsRegistry = { 'we-button': ButtonUserValueWidget, 'we-checkbox': CheckboxUserValueWidget, 'we-select': SelectUserValueWidget, 'we-button-group': ButtonGroupUserValueWidget, 'we-input': InputUserValueWidget, 'we-multi': MultiUserValueWidget, 'we-colorpicker': ColorpickerUserValueWidget, 'we-datetimepicker': DatetimePickerUserValueWidget, 'we-imagepicker': ImagepickerUserValueWidget, 'we-videopicker': VideopickerUserValueWidget, 'we-range': RangeUserValueWidget, 'we-select-pager': SelectPagerUserValueWidget, }; /** * Handles a set of options for one snippet. The registry returned by this * module contains the names of the specialized SnippetOptionWidget which can be * referenced thanks to the data-js key in the web_editor options template. */ const SnippetOptionWidget = Widget.extend({ tagName: 'we-customizeblock-option', events: { 'click .o_we_collapse_toggler': '_onCollapseTogglerClick', }, custom_events: { 'user_value_update': '_onUserValueUpdate', 'user_value_widget_critical': '_onUserValueWidgetCritical', }, /** * Indicates if the option should be displayed in the button group at the * top of the options panel, next to the clone/remove button. * * @type {boolean} */ isTopOption: false, /** * Forces the target to not be possible to remove. * * @type {boolean} */ forceNoDeleteButton: false, /** * The option `$el` is supposed to be the associated DOM UI element. * The option controls another DOM element: the snippet it * customizes, which can be found at `$target`. Access to the whole edition * overlay is possible with `$overlay` (this is not recommended though). * * @constructor */ init: function (parent, $uiElements, $target, $overlay, data, options) { this._super.apply(this, arguments); this.$originalUIElements = $uiElements; this.$target = $target; this.$overlay = $overlay; this.data = data; this.options = options; this.className = 'snippet-option-' + this.data.optionName; this.ownerDocument = this.$target[0].ownerDocument; this._userValueWidgets = []; this._actionQueues = new Map(); }, /** * @override */ willStart: async function () { await this._super(...arguments); return this._renderOriginalXML().then(uiFragment => { this.uiFragment = uiFragment; }); }, /** * @override */ renderElement: function () { this._super(...arguments); this.el.appendChild(this.uiFragment); this.uiFragment = null; }, /** * Called when the parent edition overlay is covering the associated snippet * (the first time, this follows the call to the @see start method). * * @abstract */ onFocus: function () {}, /** * Called when the parent edition overlay is covering the associated snippet * for the first time, when it is a new snippet dropped from the d&d snippet * menu. Note: this is called after the start and onFocus methods. * * @abstract */ onBuilt: function () {}, /** * Called when the parent edition overlay is removed from the associated * snippet (another snippet enters edition for example). * * @abstract */ onBlur: function () {}, /** * Called when the associated snippet is the result of the cloning of * another snippet (so `this.$target` is a cloned element). * * @abstract * @param {Object} options * @param {boolean} options.isCurrent * true if the associated snippet is a clone of the main element that * was cloned (so not a clone of a child of this main element that * was cloned) */ onClone: function (options) {}, /** * Called when the associated snippet is moved to another DOM location. * * @abstract */ onMove: function () {}, /** * Called when the associated snippet is about to be removed from the DOM. * * @abstract */ onRemove: function () {}, /** * Called when the target is shown, only meaningful if the target was hidden * at some point (typically used for 'invisible' snippets). * * @abstract * @returns {Promise|undefined} */ onTargetShow: async function () {}, /** * Called when the target is hidden (typically used for 'invisible' * snippets). * * @abstract * @returns {Promise|undefined} */ onTargetHide: async function () {}, /** * Called when the template which contains the associated snippet is about * to be saved. * * @abstract * @return {Promise|undefined} */ cleanForSave: async function () {}, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * Default option method which allows to select one and only one class in * the option classes set and set it on the associated snippet. The common * case is having a select with each item having a `data-select-class` * value allowing to choose the associated class, or simply an unique * checkbox to allow toggling a unique class. * * @param {boolean|string} previewMode * - truthy if the option is enabled for preview or if leaving it (in * that second case, the value is 'reset') * - false if the option should be activated for good * @param {string} widgetValue * @param {Object} params * @returns {Promise|undefined} */ selectClass: function (previewMode, widgetValue, params) { for (const classNames of params.possibleValues) { if (classNames) { this.$target[0].classList.remove(...classNames.trim().split(/\s+/g)); } } if (widgetValue) { this.$target[0].classList.add(...widgetValue.trim().split(/\s+/g)); } }, /** * Default option method which allows to select a value and set it on the * associated snippet as a data attribute. The name of the data attribute is * given by the attributeName parameter. * * @param {boolean} previewMode - @see this.selectClass * @param {string} widgetValue * @param {Object} params * @returns {Promise|undefined} */ selectDataAttribute: function (previewMode, widgetValue, params) { const value = this._selectAttributeHelper(widgetValue, params); this.$target[0].dataset[params.attributeName] = value; }, /** * Default option method which allows to select a value and set it on the * associated snippet as an attribute. The name of the attribute is * given by the attributeName parameter. * * @param {boolean} previewMode - @see this.selectClass * @param {string} widgetValue * @param {Object} params * @returns {Promise|undefined} */ selectAttribute: function (previewMode, widgetValue, params) { const value = this._selectAttributeHelper(widgetValue, params); this.$target[0].setAttribute(params.attributeName, value); }, /** * Default option method which allows to select a value and set it on the * associated snippet as a css style. The name of the css property is * given by the cssProperty parameter. * * @param {boolean} previewMode - @see this.selectClass * @param {string} widgetValue * @param {Object} params * @returns {Promise|undefined} */ selectStyle: function (previewMode, widgetValue, params) { // Disable all transitions for the duration of the method as many // comparisons will be done on the element to know if applying a // property has an effect or not. Also, changing a css property via the // editor should not show any transition as previews would not be done // immediately, which is not good for the user experience. this.$target[0].classList.add('o_we_force_no_transition'); const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition'); if (params.cssProperty === 'background-color') { this.$target.trigger('background-color-event', previewMode); } const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; for (const cssProp of cssProps) { // Always reset the inline style first to not put inline style on an // element which already have this style through css stylesheets. this.$target[0].style.setProperty(cssProp, ''); } if (params.extraClass) { this.$target.removeClass(params.extraClass); } // Only allow to use a color name as a className if we know about the // other potential color names (to remove) and if we know about a prefix // (otherwise we suppose that we should use the actual related color). if (params.colorNames && params.colorPrefix) { const classes = weUtils.computeColorClasses(params.colorNames, params.colorPrefix); this.$target[0].classList.remove(...classes); if (weUtils.isColorCombinationName(widgetValue)) { // Those are the special color combinations classes. Just have // to add it (and adding the potential extra class) then leave. this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`, params.extraClass); _restoreTransitions(); return; } if (params.colorNames.includes(widgetValue)) { const originalCSSValue = window.getComputedStyle(this.$target[0])[cssProps[0]]; const className = params.colorPrefix + widgetValue; this.$target[0].classList.add(className); if (originalCSSValue !== window.getComputedStyle(this.$target[0])[cssProps[0]]) { // If applying the class did indeed changed the css // property we are editing, nothing more has to be done. // (except adding the extra class) this.$target.addClass(params.extraClass); _restoreTransitions(); return; } // Otherwise, it means that class probably does not exist, // we remove it and continue. Especially useful for some // prefixes which only work with some color names but not all. this.$target[0].classList.remove(className); } } // At this point, the widget value is either a property/color name or // an actual css property value. If it is a property/color name, we will // apply a css variable as style value. const htmlPropValue = weUtils.getCSSVariableValue(widgetValue); if (htmlPropValue) { widgetValue = `var(--${widgetValue})`; } // replacing ', ' by ',' to prevent attributes with internal space separators from being split: // eg: "rgba(55, 12, 47, 1.9) 47px" should be split as ["rgba(55,12,47,1.9)", "47px"] const values = widgetValue.replace(/,\s/g, ',').split(/\s+/g); while (values.length < cssProps.length) { switch (values.length) { case 1: case 2: { values.push(values[0]); break; } case 3: { values.push(values[1]); break; } default: { values.push(values[values.length - 1]); } } } const styles = window.getComputedStyle(this.$target[0]); let hasUserValue = false; for (let i = cssProps.length - 1; i > 0; i--) { hasUserValue = applyCSS.call(this, cssProps[i], values.pop(), styles) || hasUserValue; } hasUserValue = applyCSS.call(this, cssProps[0], values.join(' '), styles) || hasUserValue; function applyCSS(cssProp, cssValue, styles) { if (!weUtils.areCssValuesEqual(styles[cssProp], cssValue)) { this.$target[0].style.setProperty(cssProp, cssValue, 'important'); return true; } return false; } if (params.extraClass) { this.$target.toggleClass(params.extraClass, hasUserValue); } _restoreTransitions(); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * Override the helper method to search inside the $target element instead * of the UI item element. * * @override */ $: function () { return this.$target.find.apply(this.$target, arguments); }, /** * Closes all user value widgets. */ closeWidgets: function () { this._userValueWidgets.forEach(widget => widget.close()); }, /** * @param {string} name * @returns {UserValueWidget|null} */ findWidget: function (name) { for (const widget of this._userValueWidgets) { if (widget.getName() === name) { return widget; } const depWidget = widget.findWidget(name); if (depWidget) { return depWidget; } } return null; }, /** * Sometimes, options may need to notify other options, even in parent * editors. This can be done thanks to the 'option_update' event, which * will then be handled by this function. * * @param {string} name - an identifier for a type of update * @param {*} data * @returns {Promise} */ notify: function (name, data) { if (name === 'target') { this.setTarget(data); } }, /** * Sometimes, an option is binded on an element but should in fact apply on * another one. For example, elements which contain slides: we want all the * per-slide options to be in the main menu of the whole snippet. This * function allows to set the option's target. * * Note: the UI is not updated accordindly automatically. * * @param {jQuery} $target - the new target element * @returns {Promise} */ setTarget: function ($target) { this.$target = $target; }, /** * Updates the UI. For widget update, @see _computeWidgetState. * * @param {boolean} [noVisibility=false] * If true, only update widget values and their UI, not their visibility * -> @see updateUIVisibility for toggling visibility only * @returns {Promise} */ updateUI: async function ({noVisibility} = {}) { // For each widget, for each of their option method, notify to the // widget the current value they should hold according to the $target's // current state, related for that method. const proms = this._userValueWidgets.map(async widget => { // Update widget value (for each method) const methodsNames = widget.getMethodsNames(); for (const methodName of methodsNames) { const params = widget.getMethodsParams(methodName); let obj = this; if (params.applyTo) { const $firstSubTarget = this.$(params.applyTo).eq(0); if (!$firstSubTarget.length) { continue; } obj = createPropertyProxy(this, '$target', $firstSubTarget); } const value = await this._computeWidgetState.call(obj, methodName, params); if (value === undefined) { continue; } const normalizedValue = this._normalizeWidgetValue(value); await widget.setValue(normalizedValue, methodName); } }); await Promise.all(proms); if (!noVisibility) { await this.updateUIVisibility(); } }, /** * Updates the UI visibility - @see _computeVisibility. For widget update, * @see _computeWidgetVisibility. * * @returns {Promise} */ updateUIVisibility: async function () { const proms = this._userValueWidgets.map(async widget => { const params = widget.getMethodsParams(); let obj = this; if (params.applyTo) { const $firstSubTarget = this.$(params.applyTo).eq(0); if (!$firstSubTarget.length) { widget.toggleVisibility(false); return; } obj = createPropertyProxy(this, '$target', $firstSubTarget); } // Make sure to check the visibility of all sub-widgets. For // simplicity and efficiency, those will be checked with main // widgets params. const allSubWidgets = [widget]; let i = 0; while (i < allSubWidgets.length) { allSubWidgets.push(...allSubWidgets[i]._userValueWidgets); i++; } const proms = allSubWidgets.map(async widget => { const show = await this._computeWidgetVisibility.call(obj, widget.getName(), params); if (!show) { widget.toggleVisibility(false); return; } const dependencies = widget.getDependencies(); const dependenciesData = []; dependencies.forEach(depName => { const toBeActive = (depName[0] !== '!'); if (!toBeActive) { depName = depName.substr(1); } const widget = this._requestUserValueWidgets(depName)[0]; if (widget) { dependenciesData.push({ widget: widget, toBeActive: toBeActive, }); } }); const dependenciesOK = !dependenciesData.length || dependenciesData.some(depData => { return (depData.widget.isActive() === depData.toBeActive); }); widget.toggleVisibility(dependenciesOK); }); return Promise.all(proms); }); const showUI = await this._computeVisibility(); this.el.classList.toggle('d-none', !showUI); await Promise.all(proms); // Hide layouting elements which contains only hidden widgets // TODO improve this, this is hackish to rely on DOM structure here. // Layouting elements should be handled as widgets or other. for (const el of this.$el.find('we-row')) { el.classList.toggle('d-none', !$(el).find('> div > .o_we_user_value_widget').not('.d-none').length); } for (const el of this.$el.find('we-collapse')) { const $el = $(el); el.classList.toggle('d-none', $el.children().first().hasClass('d-none')); const hasNoVisibleElInCollapseMenu = !$el.children().last().children().not('.d-none').length; if (hasNoVisibleElInCollapseMenu) { this._toggleCollapseEl(el, false); } el.querySelector('.o_we_collapse_toggler').classList.toggle('d-none', hasNoVisibleElInCollapseMenu); } }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private * @param {UserValueWidget[]} widgets * @returns {Promise} */ async _checkIfWidgetsUpdateNeedWarning(widgets) { const messages = []; for (const widget of widgets) { const message = widget.getMethodsParams().warnMessage; if (message) { messages.push(message); } } return messages.join(' '); }, /** * @private * @param {UserValueWidget[]} widgets * @returns {Promise} */ async _checkIfWidgetsUpdateNeedReload(widgets) { return false; }, /** * @private * @returns {Promise|boolean} */ _computeVisibility: async function () { return true; }, /** * Returns the string value that should be hold by the widget which is * related to the given method name. * * If the value is irrelevant for a method, it must return undefined. * * @private * @param {string} methodName * @param {Object} params * @returns {Promise|string|undefined} */ _computeWidgetState: async function (methodName, params) { switch (methodName) { case 'selectClass': { let maxNbClasses = 0; let activeClassNames = ''; params.possibleValues.forEach(classNames => { if (!classNames) { return; } const classes = classNames.split(/\s+/g); if (classes.length >= maxNbClasses && classes.every(className => this.$target[0].classList.contains(className))) { maxNbClasses = classes.length; activeClassNames = classNames; } }); return activeClassNames; } case 'selectAttribute': case 'selectDataAttribute': { const attrName = params.attributeName; let attrValue; if (methodName === 'selectAttribute') { attrValue = this.$target[0].getAttribute(attrName); } else if (methodName === 'selectDataAttribute') { attrValue = this.$target[0].dataset[attrName]; } attrValue = (attrValue || '').trim(); if (params.saveUnit && !params.withUnit) { attrValue = attrValue.split(/\s+/g).map(v => v + params.saveUnit).join(' '); } return attrValue || params.attributeDefaultValue || ''; } case 'selectStyle': { if (params.colorPrefix && params.colorNames) { for (const c of params.colorNames) { const className = weUtils.computeColorClasses([c], params.colorPrefix)[0]; if (this.$target[0].classList.contains(className)) { return c; } } } // Disable all transitions for the duration of the style check // as we want to know the final value of a property to properly // update the UI. this.$target[0].classList.add('o_we_force_no_transition'); const _restoreTransitions = () => this.$target[0].classList.remove('o_we_force_no_transition'); const styles = window.getComputedStyle(this.$target[0]); const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; const cssValues = cssProps.map(cssProp => { let value = styles[cssProp].trim(); if (cssProp === 'box-shadow') { const inset = value.includes('inset'); let values = value.replace(/,\s/g, ',').replace('inset', '').trim().split(/\s+/g); const color = values.find(s => !s.match(/^\d/)); values = values.join(' ').replace(color, '').trim(); value = `${color} ${values}${inset ? ' inset' : ''}`; } return value; }); if (cssValues.length === 4 && weUtils.areCssValuesEqual(cssValues[3], cssValues[1], params.cssProperty, this.$target)) { cssValues.pop(); } if (cssValues.length === 3 && weUtils.areCssValuesEqual(cssValues[2], cssValues[0], params.cssProperty, this.$target)) { cssValues.pop(); } if (cssValues.length === 2 && weUtils.areCssValuesEqual(cssValues[1], cssValues[0], params.cssProperty, this.$target)) { cssValues.pop(); } _restoreTransitions(); return cssValues.join(' '); } } }, /** * @private * @param {string} widgetName * @param {Object} params * @returns {Promise|boolean} */ _computeWidgetVisibility: async function (widgetName, params) { if (widgetName === 'move_up_opt' || widgetName === 'move_left_opt') { return !this.$target.is(':first-child'); } if (widgetName === 'move_down_opt' || widgetName === 'move_right_opt') { return !this.$target.is(':last-child'); } return true; }, /** * @private * @param {HTMLElement} el * @returns {Object} */ _extraInfoFromDescriptionElement: function (el) { return { title: el.getAttribute('string'), options: { classes: el.classList, dataAttributes: el.dataset, tooltip: el.title, placeholder: el.getAttribute('placeholder'), childNodes: [...el.childNodes], }, }; }, /** * @private * @param {*} * @returns {string} */ _normalizeWidgetValue: function (value) { value = `${value}`.trim(); // Force to a trimmed string value = ColorpickerWidget.normalizeCSSColor(value); // If is a css color, normalize it return value; }, /** * @private * @param {string} widgetName * @param {UserValueWidget|this|null} parent * @param {string} title * @param {Object} options * @returns {UserValueWidget} */ _registerUserValueWidget: function (widgetName, parent, title, options) { const widget = new userValueWidgetsRegistry[widgetName](parent, title, options, this.$target); if (!parent || parent === this) { this._userValueWidgets.push(widget); } else { parent.registerSubWidget(widget); } return widget; }, /** * @private * @param {HTMLElement} uiFragment * @returns {Promise} */ _renderCustomWidgets: function (uiFragment) { return Promise.resolve(); }, /** * @private * @param {HTMLElement} uiFragment * @returns {Promise} */ _renderCustomXML: function (uiFragment) { return Promise.resolve(); }, /** * @private * @param {jQuery} [$xml] - default to original xml content * @returns {Promise} */ _renderOriginalXML: async function ($xml) { const uiFragment = document.createDocumentFragment(); ($xml || this.$originalUIElements).clone(true).appendTo(uiFragment); await this._renderCustomXML(uiFragment); // Build layouting components first for (const [itemName, build] of [['we-row', _buildRowElement], ['we-collapse', _buildCollapseElement]]) { uiFragment.querySelectorAll(itemName).forEach(el => { const infos = this._extraInfoFromDescriptionElement(el); const groupEl = build(infos.title, infos.options); el.parentNode.insertBefore(groupEl, el); el.parentNode.removeChild(el); }); } // Load widgets await this._renderXMLWidgets(uiFragment); await this._renderCustomWidgets(uiFragment); if (this.isDestroyed()) { // TODO there is probably better to do. This case was found only in // tours, where the editor is left before the widget are fully // loaded (loadMethodsData doesn't work if the widget is destroyed). return uiFragment; } const validMethodNames = []; for (const key in this) { validMethodNames.push(key); } this._userValueWidgets.forEach(widget => { widget.loadMethodsData(validMethodNames); }); return uiFragment; }, /** * @private * @param {HTMLElement} parentEl * @param {SnippetOptionWidget|UserValueWidget} parentWidget * @returns {Promise} */ _renderXMLWidgets: function (parentEl, parentWidget) { const proms = [...parentEl.children].map(el => { const widgetName = el.tagName.toLowerCase(); if (!userValueWidgetsRegistry.hasOwnProperty(widgetName)) { return this._renderXMLWidgets(el, parentWidget); } const infos = this._extraInfoFromDescriptionElement(el); const widget = this._registerUserValueWidget(widgetName, parentWidget || this, infos.title, infos.options); return widget.insertAfter(el).then(() => { // Remove the original element afterwards as the insertion // operation may move some of its inner content during // widget start. parentEl.removeChild(el); if (widget.isContainer()) { return this._renderXMLWidgets(widget.el, widget); } }); }); return Promise.all(proms); }, /** * @private * @param {...string} widgetNames * @returns {UserValueWidget[]} */ _requestUserValueWidgets: function (...widgetNames) { const widgets = []; for (const widgetName of widgetNames) { let widget = null; this.trigger_up('user_value_widget_request', { name: widgetName, onSuccess: _widget => widget = _widget, }); if (widget) { widgets.push(widget); } } return widgets; }, /** * @private * @param {function>} [callback] * @returns {Promise} */ _rerenderXML: async function (callback) { this._userValueWidgets.forEach(widget => widget.destroy()); this._userValueWidgets = []; this.$el.empty(); let $xml = undefined; if (callback) { $xml = await callback.call(this); } return this._renderOriginalXML($xml).then(uiFragment => { this.$el.append(uiFragment); return this.updateUI(); }); }, /** * Activates the option associated to the given DOM element. * * @private * @param {boolean|string} previewMode * - truthy if the option is enabled for preview or if leaving it (in * that second case, the value is 'reset') * - false if the option should be activated for good * @param {UserValueWidget} widget - the widget which triggered the option change * @returns {Promise} */ _select: async function (previewMode, widget) { let $applyTo = null; // Call each option method sequentially for (const methodName of widget.getMethodsNames()) { const widgetValue = widget.getValue(methodName); const params = widget.getMethodsParams(methodName); if (params.applyTo) { if (!$applyTo) { $applyTo = this.$(params.applyTo); } const proms = _.map($applyTo, subTargetEl => { const proxy = createPropertyProxy(this, '$target', $(subTargetEl)); return this[methodName].call(proxy, previewMode, widgetValue, params); }); await Promise.all(proms); } else { await this[methodName](previewMode, widgetValue, params); } } // We trigger the event on elements targeted by apply-to if any as // this.$target could not be in an editable element while the elements // targeted by apply-to are. ($applyTo || this.$target).trigger('content_changed'); }, /** * Used to handle attribute or data attribute value change * * @param {string} value * @param {Object} params * @returns {string|undefined} */ _selectAttributeHelper(value, params) { if (!params.attributeName) { throw new Error('Attribute name missing'); } if (params.saveUnit && !params.withUnit) { // Values that come with an unit are saved without unit as // data-attribute unless told otherwise. value = value.split(params.saveUnit).join(''); } if (params.extraClass) { this.$target.toggleClass(params.extraClass, params.defaultValue !== value); } return value; }, /** * @private * @param {HTMLElement} collapseEl * @param {boolean|undefined} [show] */ _toggleCollapseEl(collapseEl, show) { collapseEl.classList.toggle('active', show); collapseEl.querySelector('.o_we_collapse_toggler').classList.toggle('active', show); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private * @param {Event} ev */ _onCollapseTogglerClick(ev) { const currentCollapseEl = ev.currentTarget.parentNode; this._toggleCollapseEl(currentCollapseEl); for (const collapseEl of currentCollapseEl.querySelectorAll('we-collapse')) { this._toggleCollapseEl(collapseEl, false); } }, /** * Called when a widget notifies a preview/change/reset. * * @private * @param {Event} ev */ _onUserValueUpdate: async function (ev) { ev.stopPropagation(); const widget = ev.data.widget; const previewMode = ev.data.previewMode; // First check if the updated widget or any of the widgets it triggers // will require a reload or a confirmation choice by the user. If it is // the case, warn the user and potentially ask if he agrees to save its // current changes. If not, just do nothing. let requiresReload = false; if (!ev.data.previewMode && !ev.data.isSimulatedEvent) { const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames); const widgets = [ev.data.widget].concat(linkedWidgets); const warnMessage = await this._checkIfWidgetsUpdateNeedWarning(widgets); if (warnMessage) { const okWarning = await new Promise(resolve => { Dialog.confirm(this, warnMessage, { confirm_callback: () => resolve(true), cancel_callback: () => resolve(false), }); }); if (!okWarning) { return; } } const reloadMessage = await this._checkIfWidgetsUpdateNeedReload(widgets); requiresReload = !!reloadMessage; if (requiresReload) { const save = await new Promise(resolve => { Dialog.confirm(this, _t("This change needs to reload the page, this will save all your changes and reload the page, are you sure you want to proceed?") + ' ' + (typeof reloadMessage === 'string' ? reloadMessage : ''), { confirm_callback: () => resolve(true), cancel_callback: () => resolve(false), }); }); if (!save) { return; } } } // Queue action so that we can later skip useless actions. if (!this._actionQueues.get(widget)) { this._actionQueues.set(widget, []); } const currentAction = {previewMode}; this._actionQueues.get(widget).push(currentAction); // Ask a mutexed snippet update according to the widget value change const shouldRecordUndo = (!previewMode && !ev.data.isSimulatedEvent); this.trigger_up('snippet_edition_request', {exec: async () => { // If some previous snippet edition in the mutex removed the target from // the DOM, the widget can be destroyed, in that case the edition request // is now useless and can be discarded. if (this.isDestroyed()) { return; } // Filter actions that are counterbalanced by earlier/later actions const actionQueue = this._actionQueues.get(widget).filter(({previewMode}, i, actions) => { const prev = actions[i - 1]; const next = actions[i + 1]; if (previewMode === true && next && next.previewMode) { return false; } else if (previewMode === 'reset' && prev && prev.previewMode) { return false; } return true; }); // Skip action if it's been counterbalanced if (!actionQueue.includes(currentAction)) { this._actionQueues.set(widget, actionQueue); return; } this._actionQueues.set(widget, actionQueue.filter(action => action !== currentAction)); if (ev.data.prepare) { ev.data.prepare(); } if (previewMode && (widget.$el.closest('[data-no-preview="true"]').length)) { // TODO the flag should be fetched through widget params somehow return; } // If it is not preview mode, the user selected the option for good // (so record the action) if (shouldRecordUndo) { this.trigger_up('request_history_undo_record', {$target: this.$target}); } // Call widget option methods and update $target await this._select(previewMode, widget); if (previewMode) { return; } await new Promise(resolve => setTimeout(() => { // Will update the UI of the correct widgets for all options // related to the same $target/editor this.trigger_up('snippet_option_update', { onSuccess: () => resolve(), }); // Set timeout needed so that the user event which triggered the // option can bubble first. })); }}); if (ev.data.isSimulatedEvent) { // If the user value update was simulated through a trigger, we // prevent triggering further widgets. This could be allowed at some // point but does not work correctly in complex website cases (see // customizeWebsite). return; } // Check linked widgets: force their value and simulate a notification const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames); if (linkedWidgets.length !== ev.data.triggerWidgetsNames.length) { console.warn('Missing widget to trigger'); return; } let i = 0; const triggerWidgetsValues = ev.data.triggerWidgetsValues; for (const linkedWidget of linkedWidgets) { const widgetValue = triggerWidgetsValues[i]; if (widgetValue !== undefined) { // FIXME right now only make this work supposing it is a // colorpicker widget with big big hacks, this should be // improved a lot const normValue = this._normalizeWidgetValue(widgetValue); if (previewMode === true) { linkedWidget._previewColor = normValue; } else if (previewMode === false) { linkedWidget._previewColor = false; linkedWidget._value = normValue; } else { linkedWidget._previewColor = false; } } linkedWidget.notifyValueChange(previewMode, true); i++; } if (requiresReload) { this.trigger_up('request_save', { reloadEditor: true, }); } }, /** * @private */ _onUserValueWidgetCritical() { this.trigger_up('remove_snippet', { $snippet: this.$target, }); }, }); const registry = {}; //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: registry.sizing = SnippetOptionWidget.extend({ /** * @override */ start: function () { var self = this; var def = this._super.apply(this, arguments); this.$handles = this.$overlay.find('.o_handle'); var resizeValues = this._getSize(); this.$handles.on('mousedown', function (ev) { ev.preventDefault(); // First update size values as some element sizes may not have been // initialized on option start (hidden slides, etc) resizeValues = self._getSize(); var $handle = $(ev.currentTarget); var compass = false; var XY = false; if ($handle.hasClass('n')) { compass = 'n'; XY = 'Y'; } else if ($handle.hasClass('s')) { compass = 's'; XY = 'Y'; } else if ($handle.hasClass('e')) { compass = 'e'; XY = 'X'; } else if ($handle.hasClass('w')) { compass = 'w'; XY = 'X'; } var resize = resizeValues[compass]; if (!resize) { return; } var current = 0; var cssProperty = resize[2]; var cssPropertyValue = parseInt(self.$target.css(cssProperty)); _.each(resize[0], function (val, key) { if (self.$target.hasClass(val)) { current = key; } else if (resize[1][key] === cssPropertyValue) { current = key; } }); var begin = current; var beginClass = self.$target.attr('class'); var regClass = new RegExp('\\s*' + resize[0][begin].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g'); var cursor = $handle.css('cursor') + '-important'; var $body = $(this.ownerDocument.body); $body.addClass(cursor); var xy = ev['page' + XY]; var bodyMouseMove = function (ev) { ev.preventDefault(); var dd = ev['page' + XY] - xy + resize[1][begin]; var next = current + (current + 1 === resize[1].length ? 0 : 1); var prev = current ? (current - 1) : 0; var change = false; if (dd > (2 * resize[1][next] + resize[1][current]) / 3) { self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, '')); self.$target.addClass(resize[0][next]); current = next; change = true; } if (prev !== current && dd < (2 * resize[1][prev] + resize[1][current]) / 3) { self.$target.attr('class', (self.$target.attr('class') || '').replace(regClass, '')); self.$target.addClass(resize[0][prev]); current = prev; change = true; } if (change) { self._onResize(compass, beginClass, current); self.trigger_up('cover_update'); $handle.addClass('o_active'); } }; var bodyMouseUp = function () { $body.off('mousemove', bodyMouseMove); $(window).off('mouseup', bodyMouseUp); $body.removeClass(cursor); $handle.removeClass('o_active'); // Highlights the previews for a while var $handlers = self.$overlay.find('.o_handle'); $handlers.addClass('o_active').delay(300).queue(function () { $handlers.removeClass('o_active').dequeue(); }); if (begin === current) { return; } setTimeout(function () { self.trigger_up('request_history_undo_record', { $target: self.$target, event: 'resize_' + XY, }); }, 0); }; $body.on('mousemove', bodyMouseMove); $(window).on('mouseup', bodyMouseUp); }); return def; }, /** * @override */ onFocus: function () { this._onResize(); }, /** * @override */ onBlur: function () { this.$handles.addClass('readonly'); }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ setTarget: function () { this._super(...arguments); this._onResize(); }, /** * @override */ updateUI: async function () { await this._super(...arguments); const resizeValues = this._getSize(); _.each(resizeValues, (value, key) => { this.$handles.filter('.' + key).toggleClass('readonly', !value); }); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Returns an object mapping one or several cardinal direction (n, e, s, w) * to an Array containing: * 1) A list of classes to toggle when using this cardinal direction * 2) A list of values these classes are supposed to set on a given CSS prop * 3) The mentioned CSS prop * * Note: this object must also be saved in this.grid before being returned. * * @abstract * @private * @returns {Object} */ _getSize: function () {}, /** * Called when the snippet is being resized and its classes changes. * * @private * @param {string} [compass] - resize direction ('n', 's', 'e' or 'w') * @param {string} [beginClass] - attributes class at the beginning * @param {integer} [current] - current increment in this.grid */ _onResize: function (compass, beginClass, current) { var self = this; // Adapt the resize handles according to the classes and dimensions var resizeValues = this._getSize(); var $handles = this.$overlay.find('.o_handle'); _.each(resizeValues, function (resizeValue, direction) { var classes = resizeValue[0]; var values = resizeValue[1]; var cssProperty = resizeValue[2]; var $handle = $handles.filter('.' + direction); var current = 0; var cssPropertyValue = parseInt(self.$target.css(cssProperty)); _.each(classes, function (className, key) { if (self.$target.hasClass(className)) { current = key; } else if (values[key] === cssPropertyValue) { current = key; } }); $handle.toggleClass('o_handle_start', current === 0); $handle.toggleClass('o_handle_end', current === classes.length - 1); }); // Adapt the handles to fit the left, top and bottom sizes var ml = this.$target.css('margin-left'); this.$overlay.find('.o_handle.w').css({ width: ml, left: '-' + ml, }); this.$overlay.find('.o_handle.e').css({ width: 0, }); _.each(this.$overlay.find(".o_handle.n, .o_handle.s"), function (handle) { var $handle = $(handle); var direction = $handle.hasClass('n') ? 'top' : 'bottom'; $handle.height(self.$target.css('padding-' + direction)); }); this.$target.trigger('content_changed'); }, }); /** * Handles the edition of padding-top and padding-bottom. */ registry['sizing_y'] = registry.sizing.extend({ //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _getSize: function () { var nClass = 'pt'; var nProp = 'padding-top'; var sClass = 'pb'; var sProp = 'padding-bottom'; if (this.$target.is('hr')) { nClass = 'mt'; nProp = 'margin-top'; sClass = 'mb'; sProp = 'margin-bottom'; } var grid = []; for (var i = 0; i <= (256 / 8); i++) { grid.push(i * 8); } grid.splice(1, 0, 4); this.grid = { n: [grid.map(v => nClass + v), grid, nProp], s: [grid.map(v => sClass + v), grid, sProp], }; return this.grid; }, }); /* * Abstract option to be extended by the ImageOptimize and BackgroundOptimize * options that handles all the common parts. */ const ImageHandlerOption = SnippetOptionWidget.extend({ /** * @override */ async willStart() { const _super = this._super.bind(this); await this._loadImageInfo(); return _super(...arguments); }, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * @see this.selectClass for parameters */ selectWidth(previewMode, widgetValue, params) { this._getImg().dataset.resizeWidth = widgetValue; return this._applyOptions(); }, /** * @see this.selectClass for parameters */ setQuality(previewMode, widgetValue, params) { this._getImg().dataset.quality = widgetValue; return this._applyOptions(); }, /** * @see this.selectClass for parameters */ glFilter(previewMode, widgetValue, params) { const dataset = this._getImg().dataset; if (widgetValue) { dataset.glFilter = widgetValue; } else { delete dataset.glFilter; } return this._applyOptions(); }, /** * @see this.selectClass for parameters */ customFilter(previewMode, widgetValue, params) { const img = this._getImg(); const {filterOptions} = img.dataset; const {filterProperty} = params; if (filterProperty === 'filterColor') { widgetValue = normalizeColor(widgetValue); } const newOptions = Object.assign(JSON.parse(filterOptions || "{}"), {[filterProperty]: widgetValue}); img.dataset.filterOptions = JSON.stringify(newOptions); return this._applyOptions(); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _computeVisibility() { const src = this._getImg().getAttribute('src'); return src && src !== '/'; }, /** * @override */ async _computeWidgetState(methodName, params) { const img = this._getImg(); // Make sure image is loaded because we need its naturalWidth await new Promise((resolve, reject) => { if (img.complete) { resolve(); return; } img.addEventListener('load', resolve, {once: true}); img.addEventListener('error', resolve, {once: true}); }); switch (methodName) { case 'selectWidth': return img.naturalWidth; case 'setFilter': return img.dataset.filter; case 'glFilter': return img.dataset.glFilter || ""; case 'setQuality': return img.dataset.quality || 75; case 'customFilter': { const {filterProperty} = params; const options = JSON.parse(img.dataset.filterOptions || "{}"); const defaultValue = filterProperty === 'blend' ? 'normal' : 0; return options[filterProperty] || defaultValue; } } return this._super(...arguments); }, /** * @override */ async _renderCustomXML(uiFragment) { const isLocalURL = href => new URL(href, window.location.origin).origin === window.location.origin; const img = this._getImg(); if (!this.originalSrc || !['image/png', 'image/jpeg'].includes(img.dataset.mimetype)) { return [...uiFragment.childNodes].forEach(node => { if (node.matches('.o_we_external_warning')) { node.classList.remove('d-none'); if (isLocalURL(img.getAttribute('src'))) { const title = node.querySelector('we-title'); title.textContent = ` ${_t("Quality options unavailable")}`; $(title).prepend(''); if (img.dataset.mimetype) { title.setAttribute('title', _t("Only PNG and JPEG images support quality options and image filtering")); } else { title.setAttribute('title', _t("Due to technical limitations, you can only change optimization settings on this image by choosing it again in the media-dialog or reuploading it (double click on the image)")); } } } else { node.remove(); } }); } const $select = $(uiFragment).find('we-select[data-name=width_select_opt]'); (await this._computeAvailableWidths()).forEach(([value, label]) => { $select.append(`${label}`); }); if (img.dataset.mimetype !== 'image/jpeg') { uiFragment.querySelector('we-range[data-set-quality]').remove(); } }, /** * Returns a list of valid widths for a given image. * * @private */ async _computeAvailableWidths() { const img = this._getImg(); const original = await loadImage(this.originalSrc); const maxWidth = img.dataset.width ? img.naturalWidth : original.naturalWidth; const optimizedWidth = Math.min(maxWidth, this._computeMaxDisplayWidth()); this.optimizedWidth = optimizedWidth; const widths = { 128: '128px', 256: '256px', 512: '512px', 1024: '1024px', 1920: '1920px', }; widths[img.naturalWidth] = _.str.sprintf(_t("%spx"), img.naturalWidth); widths[optimizedWidth] = _.str.sprintf(_t("%dpx (Suggested)"), optimizedWidth); widths[maxWidth] = _.str.sprintf(_t("%dpx (Original)"), maxWidth); return Object.entries(widths) .filter(([width]) => width <= maxWidth) .sort(([v1], [v2]) => v1 - v2); }, /** * Applies all selected options on the original image. * * @private */ async _applyOptions() { const img = this._getImg(); if (!['image/jpeg', 'image/png'].includes(img.dataset.mimetype)) { this.originalId = null; return; } const dataURL = await applyModifications(img); const weight = dataURL.split(',')[1].length / 4 * 3; const $weight = this.$el.find('.o_we_image_weight'); $weight.find('> small').text(_t("New size")); $weight.find('b').text(`${(weight / 1024).toFixed(1)} kb`); $weight.removeClass('d-none'); img.classList.add('o_modified_image_to_save'); const loadedImg = await loadImage(dataURL, img); this._applyImage(loadedImg); return loadedImg; }, /** * Loads the image's attachment info. * * @private */ async _loadImageInfo() { const img = this._getImg(); await loadImageInfo(img, this._rpc.bind(this)); if (!img.dataset.originalId) { this.originalId = null; this.originalSrc = null; return; } this.originalId = img.dataset.originalId; this.originalSrc = img.dataset.originalSrc; }, /** * Sets the image's width to its suggested size. * * @private */ async _autoOptimizeImage() { await this._loadImageInfo(); await this._rerenderXML(); this._getImg().dataset.resizeWidth = this.optimizedWidth; await this._applyOptions(); await this.updateUI(); }, /** * Returns the image that is currently being modified. * * @private * @abstract * @returns {HTMLImageElement} the image to use for modifications */ _getImg() {}, /** * Computes the image's maximum display width. * * @private * @abstract * @returns {Int} the maximum width at which the image can be displayed */ _computeMaxDisplayWidth() {}, /** * Use the processed image when it's needed in the DOM. * * @private * @abstract * @param {HTMLImageElement} img */ _applyImage(img) {}, }); /** * Controls image width and quality. */ registry.ImageOptimize = ImageHandlerOption.extend({ /** * @override */ start() { this.$target.on('image_changed.ImageOptimization', this._onImageChanged.bind(this)); this.$target.on('image_cropped.ImageOptimization', this._onImageCropped.bind(this)); return this._super(...arguments); }, /** * @override */ destroy() { this.$target.off('.ImageOptimization'); return this._super(...arguments); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _computeMaxDisplayWidth() { // TODO: read widths from computed style in case container widths are not default const displayWidth = this._getImg().clientWidth; // If the image is in a column, it might get bigger on smaller screens. // We use col-lg for this in snippets, so they get bigger on the md breakpoint if (this.$target.closest('[class*="col-lg"]').length) { // container and o_container_small have maximum inner width of 690px on the md breakpoint if (this.$target.closest('.container, .o_container_small').length) { return Math.min(1920, Math.max(displayWidth, 690)); } // A container-fluid's max inner width is 962px on the md breakpoint return Math.min(1920, Math.max(displayWidth, 962)); } // If it's not in a col-lg, it's probably not going to change size depending on breakpoints return displayWidth; }, /** * @override */ _getImg() { return this.$target[0]; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Reloads image data and auto-optimizes the new image. * * @private * @param {Event} ev */ async _onImageChanged(ev) { this.trigger_up('snippet_edition_request', {exec: async () => { await this._autoOptimizeImage(); this.trigger_up('cover_update'); }}); }, /** * Available widths will change, need to rerender the width select. * * @private * @param {Event} ev */ async _onImageCropped(ev) { await this._rerenderXML(); }, }); /** * Controls background image width and quality. */ registry.BackgroundOptimize = ImageHandlerOption.extend({ /** * @override */ start() { this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this)); return this._super(...arguments); }, /** * @override */ destroy() { this.$target.off('.BackgroundOptimize'); return this._super(...arguments); }, /** * Marks the target for creation of an attachment and copies data attributes * to the target so that they can be restored on this.img in later editions. * * @override */ async cleanForSave() { const img = this._getImg(); if (img.matches('.o_modified_image_to_save')) { this.$target.addClass('o_modified_image_to_save'); Object.entries(img.dataset).forEach(([key, value]) => { this.$target[0].dataset[key] = value; }); this.$target[0].dataset.bgSrc = img.getAttribute('src'); } }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _getImg() { return this.img; }, /** * @override */ _computeMaxDisplayWidth() { return 1920; }, /** * Initializes this.img to an image with the background image url as src. * * @override */ async _loadImageInfo() { this.img = new Image(); Object.entries(this.$target[0].dataset).filter(([key]) => // Avoid copying dynamic editor attributes !['oeId','oeModel', 'oeField', 'oeXpath', 'noteId'].includes(key) ).forEach(([key, value]) => { this.img.dataset[key] = value; }); const src = getBgImageURL(this.$target[0]); // Don't set the src if not relative (ie, not local image: cannot be modified) this.img.src = src.startsWith('/') ? src : ''; return await this._super(...arguments); }, /** * @override */ _applyImage(img) { this.$target.css('background-image', `url('${img.getAttribute('src')}')`); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Reloads image data when the background is changed. * * @private */ async _onBackgroundChanged(ev, previewMode) { if (!previewMode) { this.trigger_up('snippet_edition_request', {exec: async () => { await this._autoOptimizeImage(); }}); } }, }); registry.BackgroundToggler = SnippetOptionWidget.extend({ /** * @override */ start() { this.$target.on('content_changed.BackgroundToggler', this._onExternalUpdate.bind(this)); return this._super(...arguments); }, /** * @override */ destroy() { this._super(...arguments); this.$target.off('.BackgroundToggler'); }, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * Toggles background image on or off. * * @see this.selectClass for parameters */ toggleBgImage(previewMode, widgetValue, params) { if (!widgetValue) { // TODO: use setWidgetValue instead of calling background directly when possible const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); const bgImageOpt = bgImageWidget.getParent(); return bgImageOpt.background(false, '', bgImageWidget.getMethodsParams('background')); } else { // TODO: use trigger instead of el.click when possible this._requestUserValueWidgets('bg_image_opt')[0].el.click(); } }, /** * Toggles background shape on or off. * * @see this.selectClass for parameters */ toggleBgShape(previewMode, widgetValue, params) { const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); const shapeOption = shapeWidget.getParent(); // TODO: open select after shape was selected? // TODO: use setWidgetValue instead of calling shapeOption method directly when possible return shapeOption._toggleShape(); }, /** * Toggles background filter on or off. * * @see this.selectClass for parameters */ toggleBgFilter(previewMode, widgetValue, params) { if (widgetValue) { const bgFilterEl = document.createElement('div'); bgFilterEl.classList.add('o_we_bg_filter', 'bg-black-50'); const lastBackgroundEl = this._getLastPreFilterLayerElement(); if (lastBackgroundEl) { $(lastBackgroundEl).after(bgFilterEl); } else { this.$target.prepend(bgFilterEl); } } else { this.$target.find('.o_we_bg_filter').remove(); } }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _computeWidgetState(methodName, params) { switch (methodName) { case 'toggleBgImage': { const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); const bgImageOpt = bgImageWidget.getParent(); return !!bgImageOpt._computeWidgetState('background', bgImageWidget.getMethodsParams('background')); } case 'toggleBgFilter': { return this._hasBgFilter(); } case 'toggleBgShape': { const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); const shapeOption = shapeWidget.getParent(); return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape')); } } return this._super(...arguments); }, /** * @private */ _getLastPreFilterLayerElement() { return null; }, /** * @private * @returns {Boolean} */ _hasBgFilter() { return !!this.$target.find('> .o_we_bg_filter').length; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private */ _onExternalUpdate() { if (this._hasBgFilter() && !this._getLastPreFilterLayerElement() && !getBgImageURL(this.$target)) { // No 'pre-filter' background layout anymore and no more background // image: remove the background filter option. // TODO there probably is a better system to implement to do that const widget = this._requestUserValueWidgets('bg_filter_toggle_opt')[0]; widget.enable(); } }, }); /** * Handles the edition of snippet's background image. */ registry.BackgroundImage = SnippetOptionWidget.extend({ /** * @override */ start: function () { this.__customImageSrc = getBgImageURL(this.$target[0]); return this._super(...arguments); }, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * Handles a background change. * * @see this.selectClass for parameters */ background: async function (previewMode, widgetValue, params) { if (previewMode === true) { this.__customImageSrc = getBgImageURL(this.$target[0]); } else if (previewMode === 'reset') { widgetValue = this.__customImageSrc; } else { this.__customImageSrc = widgetValue; } this._setBackground(widgetValue); if (previewMode !== 'reset') { removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]); this.$target.trigger('background_changed', [previewMode]); } }, /** * Changes the main color of dynamic SVGs. * * @see this.selectClass for parameters */ async dynamicColor(previewMode, widgetValue, params) { const currentSrc = getBgImageURL(this.$target[0]); switch (previewMode) { case true: this.previousSrc = currentSrc; break; case 'reset': this.$target.css('background-image', `url('${this.previousSrc}')`); return; } const newURL = new URL(currentSrc, window.location.origin); newURL.searchParams.set('c1', normalizeColor(widgetValue)); const src = newURL.pathname + newURL.search; await loadImage(src); this.$target.css('background-image', `url('${src}')`); if (!previewMode) { this.previousSrc = src; } }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- /** * @override */ setTarget: function () { // When we change the target of this option we need to transfer the // background-image from the old target to the new one. const oldBgURL = getBgImageURL(this.$target); this._setBackground(''); this._super(...arguments); if (oldBgURL) { this._setBackground(oldBgURL); } // TODO should be automatic for all options as equal to the start method this.__customImageSrc = getBgImageURL(this.$target[0]); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _computeWidgetState: function (methodName) { switch (methodName) { case 'background': return getBgImageURL(this.$target[0]); case 'dynamicColor': return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get('c1'); } return this._super(...arguments); }, /** * @override */ _computeWidgetVisibility(widgetName, params) { if (widgetName === 'dynamic_color_opt') { const src = new URL(getBgImageURL(this.$target[0]), window.location.origin); return src.origin === window.location.origin && src.pathname.startsWith('/web_editor/shape/'); } return this._super(...arguments); }, /** * @private * @param {string} backgroundURL */ _setBackground(backgroundURL) { if (backgroundURL) { this.$target.css('background-image', `url('${backgroundURL}')`); this.$target.addClass('oe_img_bg'); } else { this.$target.css('background-image', ''); this.$target.removeClass('oe_img_bg'); } }, }); /** * Handles background shapes. */ registry.BackgroundShape = SnippetOptionWidget.extend({ /** * @override */ updateUI() { if (this.rerender) { this.rerender = false; return this._rerenderXML(); } return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * Sets the current background shape. * * @see this.selectClass for params */ shape(previewMode, widgetValue, params) { this._handlePreviewState(previewMode, () => { return {shape: widgetValue, colors: this._getDefaultColors(), flip: []}; }); }, /** * Sets the current background shape's colors. * * @see this.selectClass for params */ color(previewMode, widgetValue, params) { this._handlePreviewState(previewMode, () => { const {colorName} = params; const {colors: previousColors} = this._getShapeData(); const newColor = normalizeColor(widgetValue) || this._getDefaultColors()[colorName]; const newColors = Object.assign(previousColors, {[colorName]: newColor}); return {colors: newColors}; }); }, /** * Flips the shape on its x axis. * * @see this.selectClass for params */ flipX(previewMode, widgetValue, params) { this._flipShape(previewMode, 'x'); }, /** * Flips the shape on its y axis. * * @see this.selectClass for params */ flipY(previewMode, widgetValue, params) { this._flipShape(previewMode, 'y'); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _computeWidgetState(methodName, params) { switch (methodName) { case 'shape': { return this._getShapeData().shape; } case 'color': { const {shape, colors: customColors} = this._getShapeData(); const colors = Object.assign(this._getDefaultColors(), customColors); const color = shape && colors[params.colorName]; return color || ''; } case 'flipX': { // Compat: flip classes are no longer used but may be present in client db const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_x').length !== 0; return hasFlipClass || this._getShapeData().flip.includes('x'); } case 'flipY': { // Compat: flip classes are no longer used but may be present in client db const hasFlipClass = this.$target.find('> .o_we_shape.o_we_flip_y').length !== 0; return hasFlipClass || this._getShapeData().flip.includes('y'); } } return this._super(...arguments); }, /** * @override */ _renderCustomXML(uiFragment) { Object.keys(this._getDefaultColors()).map(colorName => { uiFragment.querySelector('[data-name="colors"]') .prepend($(``)[0]); }); uiFragment.querySelectorAll('we-select-pager we-button[data-shape]').forEach(btn => { const btnContent = document.createElement('div'); btnContent.classList.add('o_we_shape_btn_content', 'position-relative', 'border-dark'); const btnContentInnerDiv = document.createElement('div'); btnContentInnerDiv.classList.add('o_we_shape'); btnContent.appendChild(btnContentInnerDiv); const {shape} = btn.dataset; const shapeEl = btnContent.querySelector('.o_we_shape'); shapeEl.classList.add(`o_${shape.replace(/\//g, '_')}`); btn.append(btnContent); }); return uiFragment; }, /** * @override */ async _computeWidgetVisibility(widgetName, params) { if (widgetName === 'shape_none_opt') { return false; } return this._super(...arguments); }, /** * Flips the shape on its x/y axis. * * @param {boolean} previewMode * @param {'x'|'y'} axis the axis of the shape that should be flipped. */ _flipShape(previewMode, axis) { this._handlePreviewState(previewMode, () => { const flip = new Set(this._getShapeData().flip); if (flip.has(axis)) { flip.delete(axis); } else { flip.add(axis); } return {flip: [...flip]}; }); }, /** * Handles everything related to saving state before preview and restoring * it after a preview or locking in the changes when not in preview. * * @param {boolean} previewMode * @param {function} computeShapeData function to compute the new shape data. */ _handlePreviewState(previewMode, computeShapeData) { const target = this.$target[0]; const insertShapeContainer = newContainer => { const shapeContainer = target.querySelector(':scope > .o_we_shape'); if (shapeContainer) { shapeContainer.remove(); } if (newContainer) { const preShapeLayerElement = this._getLastPreShapeLayerElement(); if (preShapeLayerElement) { $(preShapeLayerElement).after(newContainer); } else { this.$target.prepend(newContainer); } } return newContainer; }; let changedShape = false; if (previewMode === 'reset') { insertShapeContainer(this.prevShapeContainer); if (this.prevShape) { target.dataset.oeShapeData = this.prevShape; } else { delete target.dataset.oeShapeData; } return; } else { if (previewMode === true) { const shapeContainer = target.querySelector(':scope > .o_we_shape'); this.prevShapeContainer = shapeContainer && shapeContainer.cloneNode(true); this.prevShape = target.dataset.oeShapeData; } const curShapeData = target.dataset.oeShapeData || {}; const newShapeData = computeShapeData(); const {shape: curShape} = curShapeData; changedShape = newShapeData.shape !== curShape; this._markShape(newShapeData); if (previewMode === false && changedShape) { // Need to rerender for correct number of colorpickers this.rerender = true; } } // Updates/removes the shape container as needed and gives it the // correct background shape const json = target.dataset.oeShapeData; const {shape, colors, flip = []} = json ? JSON.parse(json) : {}; let shapeContainer = target.querySelector(':scope > .o_we_shape'); if (!shape) { return insertShapeContainer(null); } // When changing shape we want to reset the shape container (for transparency color) if (changedShape) { shapeContainer = insertShapeContainer(null); } if (!shapeContainer) { shapeContainer = insertShapeContainer(document.createElement('div')); target.style.position = 'relative'; shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, '_')}`; } // Compat: remove old flip classes as flipping is now done inside the svg shapeContainer.classList.remove('o_we_flip_x', 'o_we_flip_y'); if (colors || flip.length) { // Custom colors/flip, overwrite shape that is set by the class $(shapeContainer).css('background-image', `url("${this._getShapeSrc()}")`); shapeContainer.style.backgroundPosition = ''; if (flip.length) { let [xPos, yPos] = $(shapeContainer) .css('background-position') .split(' ') .map(p => parseFloat(p)); // -X + 2*Y is a symmetry of X around Y, this is a symmetry around 50% xPos = flip.includes('x') ? -xPos + 100 : xPos; yPos = flip.includes('y') ? -yPos + 100 : yPos; shapeContainer.style.backgroundPosition = `${xPos}% ${yPos}%`; } } else { // Remove custom bg image and let the shape class set the bg shape $(shapeContainer).css('background-image', ''); $(shapeContainer).css('background-position', ''); } if (previewMode === false) { this.prevShapeContainer = shapeContainer.cloneNode(true); this.prevShape = target.dataset.oeShapeData; } }, /** * Overwrites shape properties with the specified data. * * @private * @param {Object} newData an object with the new data */ _markShape(newData) { const defaultColors = this._getDefaultColors(); const shapeData = Object.assign(this._getShapeData(), newData); const areColorsDefault = Object.entries(shapeData.colors).every(([colorName, colorValue]) => { return colorValue.toLowerCase() === defaultColors[colorName].toLowerCase(); }); if (areColorsDefault) { delete shapeData.colors; } if (!shapeData.shape) { delete this.$target[0].dataset.oeShapeData; } else { this.$target[0].dataset.oeShapeData = JSON.stringify(shapeData); } }, /** * @private */ _getLastPreShapeLayerElement() { const $filterEl = this.$target.find('> .o_we_bg_filter'); if ($filterEl.length) { return $filterEl[0]; } return null; }, /** * Returns the src of the shape corresponding to the current parameters. * * @private */ _getShapeSrc() { const {shape, colors, flip} = this._getShapeData(); if (!shape) { return ''; } const searchParams = Object.entries(colors) .map(([colorName, colorValue]) => { const encodedCol = encodeURIComponent(colorValue); return `${colorName}=${encodedCol}`; }); if (flip.length) { searchParams.push(`flip=${flip.sort().join('')}`); } return `/web_editor/shape/${shape}.svg?${searchParams.join('&')}`; }, /** * Retrieves current shape data from the target's dataset. * * @private * @param {HTMLElement} [target=this.$target[0]] the target on which to read * the shape data. */ _getShapeData(target = this.$target[0]) { const defaultData = { shape: '', colors: this._getDefaultColors(), flip: [], }; const json = target.dataset.oeShapeData; return json ? Object.assign(defaultData, JSON.parse(json.replace(/'/g, '"'))) : defaultData; }, /** * Returns the default colors for the currently selected shape. * * @private */ _getDefaultColors() { const $shapeContainer = this.$target.find('> .o_we_shape') .clone() .addClass('d-none') // Needs to be in document for bg-image class to take effect .appendTo(document.body); const shapeContainer = $shapeContainer[0]; $shapeContainer.css('background-image', ''); const shapeSrc = shapeContainer && getBgImageURL(shapeContainer); $shapeContainer.remove(); if (!shapeSrc) { return {}; } const url = new URL(shapeSrc, window.location.origin); return Object.fromEntries(url.searchParams.entries()); }, /** * Toggles whether there is a shape or not, to be called from bg toggler. * * @private */ _toggleShape() { if (this._getShapeData().shape) { return this._handlePreviewState(false, () => ({shape: ''})); } else { const target = this.$target[0]; const previousSibling = target.previousElementSibling; const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); const possibleShapes = shapeWidget.getMethodsParams('shape').possibleValues; let shapeToSelect; if (previousSibling) { const previousShape = this._getShapeData(previousSibling).shape; shapeToSelect = possibleShapes.find((shape, i) => { return possibleShapes[i - 1] === previousShape; }); } else { shapeToSelect = possibleShapes[1]; } return this._handlePreviewState(false, () => ({shape: shapeToSelect})); } }, }); /** * Handles the edition of snippets' background image position. */ registry.BackgroundPosition = SnippetOptionWidget.extend({ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], /** * @override */ start: function () { this._super.apply(this, arguments); this._initOverlay(); // Resize overlay content on window resize because background images // change size, and on carousel slide because they sometimes take up // more space and move elements around them. $(window).on('resize.bgposition', () => this._dimensionOverlay()); }, /** * @override */ destroy: function () { this._toggleBgOverlay(false); $(window).off('.bgposition'); this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * Sets the background type (cover/repeat pattern). * * @see this.selectClass for params */ backgroundType: function (previewMode, widgetValue, params) { this.$target.toggleClass('o_bg_img_opt_repeat', widgetValue === 'repeat-pattern'); this.$target.css('background-position', ''); this.$target.css('background-size', ''); }, /** * Saves current background position and enables overlay. * * @see this.selectClass for params */ backgroundPositionOverlay: async function (previewMode, widgetValue, params) { // Updates the internal image await new Promise(resolve => { this.img = document.createElement('img'); this.img.addEventListener('load', () => resolve()); this.img.src = getBgImageURL(this.$target[0]); }); const position = this.$target.css('background-position').split(' ').map(v => parseInt(v)); const delta = this._getBackgroundDelta(); // originalPosition kept in % for when movement in one direction doesn't make sense this.originalPosition = { left: position[0], top: position[1], }; // Convert % values to pixels for current position because mouse movement is in pixels this.currentPosition = { left: position[0] / 100 * delta.x || 0, top: position[1] / 100 * delta.y || 0, }; this._toggleBgOverlay(true); }, /** * @override */ selectStyle: function (previewMode, widgetValue, params) { if (params.cssProperty === 'background-size' && !this.$target.hasClass('o_bg_img_opt_repeat')) { // Disable the option when the image is in cover mode, otherwise // the background-size: auto style may be forced. return; } this._super(...arguments); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _computeVisibility: function () { return this._super(...arguments) && !!getBgImageURL(this.$target[0]); }, /** * @override */ _computeWidgetState: function (methodName, params) { if (methodName === 'backgroundType') { return this.$target.css('background-repeat') === 'repeat' ? 'repeat-pattern' : 'cover'; } return this._super(...arguments); }, /** * Initializes the overlay, binds events to the buttons, inserts it in * the DOM. * * @private */ _initOverlay: function () { this.$backgroundOverlay = $(qweb.render('web_editor.background_position_overlay')); this.$overlayContent = this.$backgroundOverlay.find('.o_we_overlay_content'); this.$overlayBackground = this.$overlayContent.find('.o_overlay_background'); this.$backgroundOverlay.on('click', '.o_btn_apply', () => { this.$target.css('background-position', this.$bgDragger.css('background-position')); this._toggleBgOverlay(false); }); this.$backgroundOverlay.on('click', '.o_btn_discard', () => { this._toggleBgOverlay(false); }); this.$backgroundOverlay.insertAfter(this.$overlay); }, /** * Sets the overlay in the right place so that the draggable background * renders over the target, and size the background item like the target. * * @private */ _dimensionOverlay: function () { if (!this.$backgroundOverlay.is('.oe_active')) { return; } // TODO: change #wrapwrap after web_editor rework. const $wrapwrap = $('#wrapwrap'); const targetOffset = this.$target.offset(); this.$backgroundOverlay.css({ width: $wrapwrap.innerWidth(), height: $wrapwrap.innerHeight(), }); this.$overlayContent.offset(targetOffset); this.$bgDragger.css({ width: `${this.$target.innerWidth()}px`, height: `${this.$target.innerHeight()}px`, }); const topPos = (parseInt(this.$overlay.css('top')) - parseInt(this.$overlayContent.css('top'))); this.$overlayContent.find('.o_we_overlay_buttons').css('top', `${topPos}px`); }, /** * Toggles the overlay's display and renders a background clone inside of it. * * @private * @param {boolean} activate toggle the overlay on (true) or off (false) */ _toggleBgOverlay: function (activate) { if (!this.$backgroundOverlay || this.$backgroundOverlay.is('.oe_active') === activate) { return; } if (!activate) { this.$backgroundOverlay.removeClass('oe_active'); this.trigger_up('unblock_preview_overlays'); this.trigger_up('activate_snippet', {$snippet: this.$target}); $(document).off('click.bgposition'); return; } this.trigger_up('hide_overlay'); this.trigger_up('activate_snippet', { $snippet: this.$target, previewMode: true, }); this.trigger_up('block_preview_overlays'); // Create empty clone of $target with same display size, make it draggable and give it a tooltip. this.$bgDragger = this.$target.clone().empty(); // Prevent clone from being seen as editor if target is editor (eg. background on root tag) this.$bgDragger.removeClass('o_editable'); // Some CSS child selector rules will not be applied since the clone has a different container from $target. // The background-attachment property should be the same in both $target & $bgDragger, this will keep the // preview more "wysiwyg" instead of getting different result when bg position saved (e.g. parallax snippet) // TODO: improve this to copy all style from $target and override it with overlay related style (copying all // css into $bgDragger will not work since it will change overlay content style too). this.$bgDragger.css('background-attachment', this.$target.css('background-attachment')); this.$bgDragger.on('mousedown', this._onDragBackgroundStart.bind(this)); this.$bgDragger.tooltip({ title: 'Click and drag the background to adjust its position!', trigger: 'manual', container: this.$backgroundOverlay }); // Replace content of overlayBackground, activate the overlay and give it the right dimensions. this.$overlayBackground.empty().append(this.$bgDragger); this.$backgroundOverlay.addClass('oe_active'); this._dimensionOverlay(); this.$bgDragger.tooltip('show'); // Needs to be deferred or the click event that activated the overlay deactivates it as well. // This is caused by the click event which we are currently handling bubbling up to the document. window.setTimeout(() => $(document).on('click.bgposition', this._onDocumentClicked.bind(this)), 0); }, /** * Returns the difference between the target's size and the background's * rendered size. Background position values in % are a percentage of this. * * @private */ _getBackgroundDelta: function () { const bgSize = this.$target.css('background-size'); if (bgSize !== 'cover') { let [width, height] = bgSize.split(' '); if (width === 'auto' && (height === 'auto' || !height)) { return { x: this.$target.outerWidth() - this.img.naturalWidth, y: this.$target.outerHeight() - this.img.naturalHeight, }; } // At least one of width or height is not auto, so we can use it to calculate the other if it's not set [width, height] = [parseInt(width), parseInt(height)]; return { x: this.$target.outerWidth() - (width || (height * this.img.naturalWidth / this.img.naturalHeight)), y: this.$target.outerHeight() - (height || (width * this.img.naturalHeight / this.img.naturalWidth)), }; } const renderRatio = Math.max( this.$target.outerWidth() / this.img.naturalWidth, this.$target.outerHeight() / this.img.naturalHeight ); return { x: this.$target.outerWidth() - Math.round(renderRatio * this.img.naturalWidth), y: this.$target.outerHeight() - Math.round(renderRatio * this.img.naturalHeight), }; }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * Drags the overlay's background image, copied to target on "Apply". * * @private */ _onDragBackgroundStart: function (ev) { ev.preventDefault(); this.$bgDragger.addClass('o_we_grabbing'); const $document = $(this.ownerDocument); $document.on('mousemove.bgposition', this._onDragBackgroundMove.bind(this)); $document.one('mouseup', () => { this.$bgDragger.removeClass('o_we_grabbing'); $document.off('mousemove.bgposition'); }); }, /** * Drags the overlay's background image, copied to target on "Apply". * * @private */ _onDragBackgroundMove: function (ev) { ev.preventDefault(); const delta = this._getBackgroundDelta(); this.currentPosition.left = clamp(this.currentPosition.left + ev.originalEvent.movementX, [0, delta.x]); this.currentPosition.top = clamp(this.currentPosition.top + ev.originalEvent.movementY, [0, delta.y]); const percentPosition = { left: this.currentPosition.left / delta.x * 100, top: this.currentPosition.top / delta.y * 100, }; // In cover mode, one delta will be 0 and dividing by it will yield Infinity. // Defaulting to originalPosition in that case (can't be dragged) percentPosition.left = isFinite(percentPosition.left) ? percentPosition.left : this.originalPosition.left; percentPosition.top = isFinite(percentPosition.top) ? percentPosition.top : this.originalPosition.top; this.$bgDragger.css('background-position', `${percentPosition.left}% ${percentPosition.top}%`); function clamp(val, bounds) { // We sort the bounds because when one dimension of the rendered background is // larger than the container, delta is negative, and we want to use it as lower bound bounds = bounds.sort(); return Math.max(bounds[0], Math.min(val, bounds[1])); } }, /** * Deactivates the overlay if the user clicks outside of it. * * @private */ _onDocumentClicked: function (ev) { if (!$(ev.target).closest('.o_we_background_position_overlay')) { this._toggleBgOverlay(false); } }, }); /** * Marks color levels of any element that may get or has a color classes. This * is done for the specific main colorpicker option so that those are marked on * snippet drop (so that base snippet definition do not need to care about that) * and on first focus (for compatibility). */ registry.ColoredLevelBackground = registry.BackgroundToggler.extend({ /** * @override */ start: function () { this._markColorLevel(); return this._super(...arguments); }, /** * @override */ onBuilt: function () { this._markColorLevel(); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Adds a specific class indicating the element is colored so that nested * color classes work (we support one-level). Removing it is not useful, * technically the class can be added on anything that *may* receive a color * class: this does not come with any CSS rule. * * @private */ _markColorLevel: function () { this.$target.addClass('o_colored_level'); }, }); /** * Allows to replace a text value with the name of a database record. * @todo replace this mechanism with real backend m2o field ? */ registry.many2one = SnippetOptionWidget.extend({ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], /** * @override */ start: function () { var self = this; this.trigger_up('getRecordInfo', _.extend(this.options, { callback: function (recordInfo) { _.defaults(self.options, recordInfo); }, })); this.Model = this.$target.data('oe-many2one-model'); this.ID = +this.$target.data('oe-many2one-id'); // create search button and bind search bar this.$btn = $(qweb.render('web_editor.many2one.button')) .prependTo(this.$el); this.$ul = this.$btn.find('ul'); this.$search = this.$ul.find('li:first'); this.$search.find('input').on('mousedown click mouseup keyup keydown', function (e) { e.stopPropagation(); }); // move menu item setTimeout(function () { self.$btn.find('a').on('click', function (e) { self._clear(); }); }, 0); // bind search input this.$search.find('input') .focus() .on('keyup', function (e) { self.$overlay.removeClass('o_overlay_hidden'); self._findExisting($(this).val()); }); // bind result this.$ul.on('click', 'li:not(:first) a', function (e) { self._selectRecord($(e.currentTarget)); }); return this._super.apply(this, arguments); }, /** * @override */ onFocus: function () { this.$target.attr('contentEditable', 'false'); this._clear(); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * Removes the input value and suggestions. * * @private */ _clear: function () { var self = this; this.$search.siblings().remove(); self.$search.find('input').val(''); setTimeout(function () { self.$search.find('input').focus(); }, 0); }, /** * Find existing record with the given name and suggest them. * * @private * @param {string} name * @returns {Promise} */ _findExisting: function (name) { var self = this; var domain = []; if (!name || !name.length) { self.$search.siblings().remove(); return; } if (isNaN(+name)) { if (this.Model !== 'res.partner') { domain.push(['name', 'ilike', name]); } else { domain.push('|', ['name', 'ilike', name], ['email', 'ilike', name]); } } else { domain.push(['id', '=', name]); } return this._rpc({ model: this.Model, method: 'search_read', args: [domain, this.Model === 'res.partner' ? ['name', 'display_name', 'city', 'country_id'] : ['name', 'display_name']], kwargs: { order: [{name: 'name', asc: false}], limit: 5, context: this.options.context, }, }).then(function (result) { self.$search.siblings().remove(); self.$search.after(qweb.render('web_editor.many2one.search', {contacts: result})); }); }, /** * Selects the given suggestion and displays it the proper way. * * @private * @param {jQuery} $li */ _selectRecord: function ($li) { var self = this; this.ID = +$li.data('id'); this.$target.attr('data-oe-many2one-id', this.ID).data('oe-many2one-id', this.ID); this.trigger_up('request_history_undo_record', {$target: this.$target}); this.$target.trigger('content_changed'); if (self.$target.data('oe-type') === 'contact') { $('[data-oe-contact-options]') .filter('[data-oe-model="' + self.$target.data('oe-model') + '"]') .filter('[data-oe-id="' + self.$target.data('oe-id') + '"]') .filter('[data-oe-field="' + self.$target.data('oe-field') + '"]') .filter('[data-oe-contact-options!="' + self.$target.data('oe-contact-options') + '"]') .add(self.$target) .attr('data-oe-many2one-id', self.ID).data('oe-many2one-id', self.ID) .each(function () { var $node = $(this); var options = $node.data('oe-contact-options'); self._rpc({ model: 'ir.qweb.field.contact', method: 'get_record_to_html', args: [[self.ID]], kwargs: { options: options, context: self.options.context, }, }).then(function (html) { $node.html(html); }); }); } else { self.$target.text($li.data('name')); } this._clear(); } }); /** * Allows to display a warning message on outdated snippets. */ registry.VersionControl = SnippetOptionWidget.extend({ xmlDependencies: ['/web_editor/static/src/xml/snippets.xml'], /** * @override */ start: function () { this.trigger_up('get_snippet_versions', { snippetName: this.$target[0].dataset.snippet, onSuccess: snippetVersions => { const isUpToDate = snippetVersions && ['vjs', 'vcss', 'vxml'].every(key => this.$target[0].dataset[key] === snippetVersions[key]); if (!isUpToDate) { this.$el.prepend(qweb.render('web_editor.outdated_block_message')); } }, }); return this._super(...arguments); }, }); /** * Handle the save of a snippet as a template that can be reused later */ registry.SnippetSave = SnippetOptionWidget.extend({ xmlDependencies: ['/web_editor/static/src/xml/editor.xml'], isTopOption: true, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * @see this.selectClass for parameters */ saveSnippet: function (previewMode, widgetValue, params) { return new Promise(resolve => { const dialog = new Dialog(this, { title: _t("Save Your Block"), size: 'small', $content: $(qweb.render('web_editor.dialog.save_snippet', { currentSnippetName: _.str.sprintf(_t("Custom %s"), this.data.snippetName), })), buttons: [{ text: _t("Save"), classes: 'btn-primary', close: true, click: async () => { const save = await new Promise(resolve => { Dialog.confirm(this, _t("To save a snippet, we need to save all your previous modifications and reload the page."), { buttons: [ { text: _t("Save and Reload"), classes: 'btn-primary', close: true, click: () => resolve(true), }, { text: _t("Cancel"), close: true, click: () => resolve(false), } ] }); }); if (!save) { return; } const snippetKey = this.$target[0].dataset.snippet; let thumbnailURL; this.trigger_up('snippet_thumbnail_url_request', { key: snippetKey, onSuccess: url => thumbnailURL = url, }); let context; this.trigger_up('context_get', { callback: ctx => context = ctx, }); this.trigger_up('request_save', { reloadEditor: true, onSuccess: async () => { const snippetName = dialog.el.querySelector('.o_we_snippet_name_input').value; const targetCopyEl = this.$target[0].cloneNode(true); delete targetCopyEl.dataset.name; // By the time onSuccess is called after request_save, the // current widget has been destroyed and is orphaned, so this._rpc // will not work as it can't trigger_up. For this reason, we need // to bypass the service provider and use the global RPC directly await rpc.query({ model: 'ir.ui.view', method: 'save_snippet', kwargs: { 'name': snippetName, 'arch': targetCopyEl.outerHTML, 'template_key': this.options.snippets, 'snippet_key': snippetKey, 'thumbnail_url': thumbnailURL, 'context': context, }, }); }, }); }, }, { text: _t("Discard"), close: true, }], }).open(); dialog.on('closed', this, () => resolve()); }); }, }); /** * Handles the dynamic colors for dynamic SVGs. */ registry.DynamicSvg = SnippetOptionWidget.extend({ /** * @override */ start() { this.$target.on('image_changed.DynamicSvg', this._onImageChanged.bind(this)); return this._super(...arguments); }, /** * @override */ destroy() { this.$target.off('.DynamicSvg'); return this._super(...arguments); }, //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- /** * Sets the dynamic SVG's dynamic color. * * @see this.selectClass for params */ async color(previewMode, widgetValue, params) { const target = this.$target[0]; switch (previewMode) { case true: this.previousSrc = target.getAttribute('src'); break; case 'reset': target.src = this.previousSrc; return; } const newURL = new URL(target.src, window.location.origin); newURL.searchParams.set('c1', normalizeColor(widgetValue)); const src = newURL.pathname + newURL.search; await loadImage(src); target.src = src; if (!previewMode) { this.previousSrc = src; } }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @override */ _computeWidgetState(methodName, params) { switch (methodName) { case 'color': return new URL(this.$target[0].src, window.location.origin).searchParams.get('c1'); } return this._super(...arguments); }, /** * @override */ _computeVisibility(methodName, params) { return this.$target.is("img[src^='/web_editor/shape/']"); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @override */ _onImageChanged(methodName, params) { return this.updateUI(); }, }); return { SnippetOptionWidget: SnippetOptionWidget, snippetOptionRegistry: registry, NULL_ID: NULL_ID, UserValueWidget: UserValueWidget, userValueWidgetsRegistry: userValueWidgetsRegistry, UnitUserValueWidget: UnitUserValueWidget, addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes, buildElement: _buildElement, buildTitleElement: _buildTitleElement, buildRowElement: _buildRowElement, buildCollapseElement: _buildCollapseElement, // Other names for convenience Class: SnippetOptionWidget, registry: registry, }; });