diff --git a/addons/mass_mailing/__manifest__.py b/addons/mass_mailing/__manifest__.py index dc7d96a7e4cab..fc6edf19773d8 100644 --- a/addons/mass_mailing/__manifest__.py +++ b/addons/mass_mailing/__manifest__.py @@ -157,6 +157,8 @@ 'mass_mailing.assets_snippets_menu': [ ('include', 'web_editor.assets_snippets_menu'), 'mass_mailing/static/src/js/snippets.editor.js', + 'mass_mailing/static/src/js/snippets.registry.js', + 'mass_mailing/static/src/js/design_tab.xml', 'mass_mailing/static/src/xml/mass_mailing.editor.xml', ], 'web.assets_frontend': [ diff --git a/addons/mass_mailing/static/src/js/design_tab.xml b/addons/mass_mailing/static/src/js/design_tab.xml new file mode 100644 index 0000000000000..892a6a3c81f03 --- /dev/null +++ b/addons/mass_mailing/static/src/js/design_tab.xml @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Small + Medium + Large + + + + + + + + +
+ + +
+ + +
+ + +
+ + + + + + + + + + + + Small + Medium + Large + + + + + + + + +
+ + +
+ + +
+ + +
+ + + + + + + + + +
+ + +
+ + +
+ + +
+ + + + + + 25% + 50% + 75% + 100% + + + diff --git a/addons/mass_mailing/static/src/js/mass_mailing_snippets.js b/addons/mass_mailing/static/src/js/mass_mailing_snippets.js index fede7a6f9cbf6..a98b13c2157a3 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_snippets.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_snippets.js @@ -1,6 +1,7 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import { registry } from "@web/core/registry"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import { loadImage } from "@web_editor/js/editor/image_processing"; const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select']; import weUtils from "@web_editor/js/common/utils"; @@ -11,7 +12,12 @@ import { transformFontFamilySelector, } from "@mass_mailing/js/mass_mailing_design_constants"; import { isCSSColor, normalizeCSSColor } from "@web/core/utils/colors"; - +import { + SnippetOption, + WeButton, + WeSelect, +} from "@web_editor/js/editor/snippets.options"; +import { registerMassMailingOption } from "./snippets.registry"; //-------------------------------------------------------------------------- // Options @@ -67,41 +73,35 @@ options.registry.MassMailingImageTools = options.registry.ImageTools.extend({ } }); -options.userValueWidgetsRegistry['we-fontfamilypicker'] = SelectUserValueWidget.extend({ - /** - * @override - * @see FONT_FAMILIES - */ - start: async function () { - const res = await this._super(...arguments); - // Populate the `we-select` with the font family buttons - for (const fontFamily of FONT_FAMILIES) { - const button = document.createElement('we-button'); - button.style.setProperty('font-family', fontFamily); - button.dataset.customizeCssProperty = fontFamily; - button.dataset.cssProperty = 'font-family'; - button.dataset.selectorText = this.el.dataset.selectorText; - button.textContent = getFontName(fontFamily); - this.menuEl.appendChild(button); - }; - return res; - }, -}); +class MassMailingWeFontFamilyPicker extends WeSelect { + static isContainer = true; + static template = "mass_mailing.MassMailingWeFontFamilyPicker"; + static components = { WeSelect, WeButton }; + + setup() { + super.setup(); + this.fontFamilies = FONT_FAMILIES; + this.getFontName = getFontName; + } +} +// Widget registry is shared - prefixing component name to avoid collision. +registry.category("snippet_widgets").add("MassMailingWeFontFamilyPicker", MassMailingWeFontFamilyPicker); + -options.registry.DesignTab = options.Class.extend({ +export class DesignTab extends SnippetOption { /** * @override */ - init() { - this._super(...arguments); + constructor() { + super(...arguments); // Set the target on the whole editable so apply-to looks within it. this.setTarget(this.options.wysiwyg.getEditable()); - }, + } /** * @override */ - async start() { - const res = await this._super(...arguments); + async willStart() { + const res = await super.willStart(...arguments); const $editable = this.options.wysiwyg.getEditable(); this.document = $editable[0].ownerDocument; this.$layout = $editable.find('.o_layout'); @@ -123,7 +123,7 @@ options.registry.DesignTab = options.Class.extend({ sheetOwner.textContent = this.styleElement.textContent; this.styleSheet = sheetOwner.sheet; return res; - }, + } //-------------------------------------------------------------------------- // Public @@ -196,7 +196,7 @@ options.registry.DesignTab = options.Class.extend({ } } this._commitCss(); - }, + } /** * Option method to change the size of buttons. * @@ -221,7 +221,7 @@ options.registry.DesignTab = options.Class.extend({ } } this._commitCss(); - }, + } //-------------------------------------------------------------------------- // Private @@ -239,12 +239,12 @@ options.registry.DesignTab = options.Class.extend({ // Flush the rules cache for convert_inline, to make sure they are // recomputed to account for the change. this.options.wysiwyg._rulesCache = undefined; - }, + } /** * @override */ async _computeWidgetState(methodName, params) { - const res = await this._super(...arguments); + const res = await super._computeWidgetState(...arguments); if (res === undefined) { switch (methodName) { case 'applyButtonSize': @@ -299,7 +299,7 @@ options.registry.DesignTab = options.Class.extend({ } else { return res; } - }, + } /** * Take a CSS selector and split it into separate selectors, all prefixed * with the `CSS_PREFIX`. Return them as an array. @@ -310,7 +310,7 @@ options.registry.DesignTab = options.Class.extend({ */ _getSelectors(selectorText) { return selectorText.split(',').map(t => `${t.startsWith(CSS_PREFIX) ? '' : CSS_PREFIX + ' '}${t.trim()}`.trim());; - }, + } /** * Take a CSS selector and find its matching rule in the mailing's custom * stylesheet, if it exists. @@ -320,5 +320,12 @@ options.registry.DesignTab = options.Class.extend({ */ _getRule(selectorText) { return [...(this.styleSheet.cssRules || this.styleSheet.rules)].find(rule => rule.selectorText === selectorText); - }, + } +} + +registerMassMailingOption("DesignTab", { + Class: DesignTab, + template: "mass_mailing.design_tab", + selector: "design-options", + noCheck: true, }); diff --git a/addons/mass_mailing/static/src/js/snippets.editor.js b/addons/mass_mailing/static/src/js/snippets.editor.js index 7df787c1d05eb..57768dfe15f1c 100644 --- a/addons/mass_mailing/static/src/js/snippets.editor.js +++ b/addons/mass_mailing/static/src/js/snippets.editor.js @@ -84,6 +84,15 @@ export class MassMailingSnippetsMenu extends snippetsEditor.SnippetsMenu { html.querySelectorAll('img').forEach(img => img.setAttribute("loading", "lazy")); return super._computeSnippetTemplates(html); } + /** + * @override + */ + getOptions() { + const options = super.getOptions().filter(([, option]) => { + return ["mass_mailing", "web_editor"].includes(option.module); + }); + return options; + } /** * @override */ diff --git a/addons/mass_mailing/static/src/js/snippets.registry.js b/addons/mass_mailing/static/src/js/snippets.registry.js new file mode 100644 index 0000000000000..83bfdb882eab6 --- /dev/null +++ b/addons/mass_mailing/static/src/js/snippets.registry.js @@ -0,0 +1,8 @@ +import { registerOption } from "@web_editor/js/editor/snippets.registry"; + +export function registerMassMailingOption(name, def, options) { + if (!def.module) { + def.module = "mass_mailing"; + } + return registerOption(name, def, options); +} diff --git a/addons/mass_mailing/static/src/snippets/s_masonry_block/options.js b/addons/mass_mailing/static/src/snippets/s_masonry_block/options.js index f9d5a83076497..ddfcf7ab9d9d7 100644 --- a/addons/mass_mailing/static/src/snippets/s_masonry_block/options.js +++ b/addons/mass_mailing/static/src/snippets/s_masonry_block/options.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; options.registry.MasonryLayout = options.registry.SelectTemplate.extend({ /** diff --git a/addons/mass_mailing/static/src/snippets/s_media_list/options.js b/addons/mass_mailing/static/src/snippets/s_media_list/options.js index 66030ae3bbf32..e732152348c76 100644 --- a/addons/mass_mailing/static/src/snippets/s_media_list/options.js +++ b/addons/mass_mailing/static/src/snippets/s_media_list/options.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; options.registry.MediaItemLayout = options.Class.extend({ diff --git a/addons/mass_mailing/static/src/snippets/s_rating/options.js b/addons/mass_mailing/static/src/snippets/s_rating/options.js index 138f57ae81132..bf7121b16284b 100644 --- a/addons/mass_mailing/static/src/snippets/s_rating/options.js +++ b/addons/mass_mailing/static/src/snippets/s_rating/options.js @@ -1,7 +1,7 @@ /** @odoo-module **/ import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; options.registry.Rating = options.Class.extend({ /** diff --git a/addons/mass_mailing/static/src/snippets/s_showcase/options.js b/addons/mass_mailing/static/src/snippets/s_showcase/options.js index 296ad494d2e3e..d8be3637a6e92 100644 --- a/addons/mass_mailing/static/src/snippets/s_showcase/options.js +++ b/addons/mass_mailing/static/src/snippets/s_showcase/options.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; options.registry.Showcase = options.Class.extend({ /** diff --git a/addons/mass_mailing/views/snippets_themes.xml b/addons/mass_mailing/views/snippets_themes.xml index da168857abe73..968eb309a0858 100644 --- a/addons/mass_mailing/views/snippets_themes.xml +++ b/addons/mass_mailing/views/snippets_themes.xml @@ -509,306 +509,6 @@
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Small - Medium - Large - - - - - - - - -
- - -
- - -
- - -
- - - - - - - - - - - - Small - Medium - Large - - - - - - - - -
- - -
- - -
- - -
- - - - - - - - - -
- - -
- - -
- - -
- - - - - - 25% - 50% - 75% - 100% - -
- diff --git a/addons/web/static/src/core/utils/draggable_hook_builder.js b/addons/web/static/src/core/utils/draggable_hook_builder.js index 0ab399ce6d168..1fac63391f4ff 100644 --- a/addons/web/static/src/core/utils/draggable_hook_builder.js +++ b/addons/web/static/src/core/utils/draggable_hook_builder.js @@ -1,6 +1,7 @@ import { clamp } from "@web/core/utils/numbers"; import { omit } from "@web/core/utils/objects"; import { closestScrollableX, closestScrollableY } from "@web/core/utils/scrolling"; +import { camelToKebab } from "@web/core/utils/strings"; import { setRecurringAnimationFrame } from "@web/core/utils/timing"; import { browser } from "../browser/browser"; import { hasTouch, isBrowserFirefox, isIOS } from "../browser/feature_detection"; @@ -121,17 +122,6 @@ const WHITE_LISTED_KEYS = ["Alt", "Control", "Meta", "Shift"]; */ const elCache = {}; -/** - * Transforms a camelCased string to return its kebab-cased version. - * Typically used to generate CSS properties from JS objects. - * - * @param {string} str - * @returns {string} - */ -function camelToKebab(str) { - return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); -} - /** * @template T * @param {T | () => T} valueOrFn diff --git a/addons/web/static/src/core/utils/strings.js b/addons/web/static/src/core/utils/strings.js index 8d54cd444e078..fca28a6aa9762 100644 --- a/addons/web/static/src/core/utils/strings.js +++ b/addons/web/static/src/core/utils/strings.js @@ -355,3 +355,14 @@ export function isNumeric(value) { export function exprToBoolean(str, trueIfEmpty = false) { return str ? !/^false|0$/i.test(str) : trueIfEmpty; } + +/** + * Transforms a camelCased string to return its kebab-cased version. + * Typically used to generate CSS properties from JS objects. + * + * @param {string} str + * @returns {string} + */ +export function camelToKebab(str) { + return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); +} diff --git a/addons/web_editor/__manifest__.py b/addons/web_editor/__manifest__.py index 9d573efda6a36..2086cd02637f0 100644 --- a/addons/web_editor/__manifest__.py +++ b/addons/web_editor/__manifest__.py @@ -25,6 +25,9 @@ 'web_editor.assets_snippets_menu': [ 'web_editor/static/src/js/editor/snippets.editor.js', 'web_editor/static/src/js/editor/snippets.options.js', + 'web_editor/static/src/js/editor/snippets.options.xml', + 'web_editor/static/src/js/editor/snippets.options.legacy.js', + 'web_editor/static/src/js/editor/snippets.registry.js', ], 'web_editor.wysiwyg_iframe_editor_assets': [ ('include', 'web._assets_helpers'), diff --git a/addons/web_editor/static/src/js/common/column_layout_mixin.js b/addons/web_editor/static/src/js/common/column_layout_mixin.js index 2452e0f677642..c0354110cc732 100644 --- a/addons/web_editor/static/src/js/common/column_layout_mixin.js +++ b/addons/web_editor/static/src/js/common/column_layout_mixin.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -export const ColumnLayoutMixin = { +export const ColumnLayoutUtils = { /** * Calculates the number of columns for the mobile or desktop version. * If all elements don't have the same size, returns "custom". @@ -112,3 +112,10 @@ export const ColumnLayoutMixin = { }); }, }; + +export const ColumnLayoutMixin = (T) => class extends T { + constructor() { + super(...arguments); + Object.assign(this, ColumnLayoutUtils); + } +}; diff --git a/addons/web_editor/static/src/js/common/utils.js b/addons/web_editor/static/src/js/common/utils.js index bbc56b80222b4..7cde596edc250 100644 --- a/addons/web_editor/static/src/js/common/utils.js +++ b/addons/web_editor/static/src/js/common/utils.js @@ -341,11 +341,19 @@ function _normalizeColor(color) { /** * Parse an element's background-image's url. * - * @param {string} string a css value in the form 'url("...")' + * @param {string|HTMLElement} string a css value in the form 'url("...")' * @returns {string|false} the src of the image or false if not parsable */ function _getBgImageURL(el) { - const parts = _backgroundImageCssToParts($(el).css('background-image')); + let value = el; + // Testing if the element is HTMLElement would make more sense, but in + // some browsers the HTMLElement is different in the window and iframe. + // So just assume that the arguments passed are either string, jQuery + // or HTMLElement. + if (!(el instanceof String)) { + value = $(el).css('background-image'); + } + const parts = _backgroundImageCssToParts(value); const string = parts.url || ''; const match = string.match(/^url\((['"])(.*?)\1\)$/); if (!match) { diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index f6cc777a0d62b..62fca33e08c3f 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -2,16 +2,17 @@ import { clamp } from "@web/core/utils/numbers"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { registry } from "@web/core/registry"; import { useService, useBus } from "@web/core/utils/hooks"; import dom from "@web/legacy/js/core/dom"; import Widget from "@web/legacy/js/core/widget"; import { useDragAndDrop } from "@web_editor/js/editor/drag_and_drop"; -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import weUtils from "@web_editor/js/common/utils"; import * as gridUtils from "@web_editor/js/common/grid_layout_utils"; import { escape } from "@web/core/utils/strings"; import { closestElement, isUnremovable } from "@web_editor/js/editor/odoo-editor/src/utils/utils"; -import { debounce, throttleForAnimation } from "@web/core/utils/timing"; +import { batched, debounce, throttleForAnimation } from "@web/core/utils/timing"; import { uniqueId } from "@web/core/utils/functions"; import { sortBy, unique } from "@web/core/utils/arrays"; import { browser } from "@web/core/browser/browser"; @@ -20,21 +21,24 @@ import { Component, EventBus, markup, + markRaw, onMounted, onWillStart, onWillUnmount, useEffect, useRef, useState, + useSubEnv, } from "@odoo/owl"; import { LinkTools } from '@web_editor/js/wysiwyg/widgets/link_tools'; import { touching, closest, addLoadingEffect as addButtonLoadingEffect } from "@web/core/utils/ui"; import { _t } from "@web/core/l10n/translation"; import { renderToElement } from "@web/core/utils/render"; import { RPCError } from "@web/core/network/rpc"; -import { ColumnLayoutMixin } from "@web_editor/js/common/column_layout_mixin"; +import { ColumnLayoutUtils } from "@web_editor/js/common/column_layout_mixin"; import { Tooltip as OdooTooltip } from "@web/core/tooltip/tooltip"; import { AddSnippetDialog } from "@web_editor/js/editor/add_snippet_dialog"; +import { SnippetOption, SnippetOptionComponent, clearServiceCache } from "./snippets.options"; let cacheSnippetTemplate = {}; @@ -91,6 +95,7 @@ var SnippetEditor = Widget.extend({ this.$target = $(target); this.$target.data('snippet-editor', this); this.templateOptions = templateOptions; + this.key = uniqueId("snippet-editor"); this.isTargetParentEditable = false; this.isTargetMovable = false; this.$scrollingElement = $().getScrollingElement(this.$editable[0].ownerDocument); @@ -193,6 +198,9 @@ var SnippetEditor = Widget.extend({ * @override */ destroy: function () { + for (const option of this.snippetOptions) { + option.instance.destroy(); + } // Before actually destroying a snippet editor, notify the parent // about it so that it can update its list of alived snippet editors. this.trigger_up('snippet_editor_destroyed'); @@ -614,7 +622,15 @@ var SnippetEditor = Widget.extend({ if (editorUIsToUpdate.length > 0 && !optionsSectionVisible) { return null; } - return this._customize$Elements; + return this.getOptions(); + }, + /** + * Returns the OWL Options templates to mount their widgets. + * The widgets will handle requesting the initial updateUI + * @TODO owl-options Implement visibility. + */ + getOptions() { + return this.snippetOptions.filter((option) => !option.instance.isRestrictedGroup); }, /** * @param {boolean} [show] @@ -740,6 +756,7 @@ var SnippetEditor = Widget.extend({ */ _initializeOptions: function () { this._customize$Elements = []; + this.snippetOptions = []; this.styles = {}; this.selectorSiblings = []; this.selectorChildren = []; @@ -752,6 +769,11 @@ var SnippetEditor = Widget.extend({ if (parentEditor) { this._customize$Elements = this._customize$Elements .concat(parentEditor._customize$Elements); + // TODO: @owl-options This was meant to have the same behavior + // as the old editor, but as of now, options are just added + // with a loop on "enabledEditorHierarchy", so this would lead + // to duplicate option Components. + // this.snippetOptions = [...this.snippetOptions,...parentEditor.snippetOptions]; break; } $element = $element.parent(); @@ -783,7 +805,7 @@ var SnippetEditor = Widget.extend({ if (!val.selector.is(this.$target)) { return; } - if (val.data.string) { + if (val.data.string && !val.isOwl) { $optionsSection[0].querySelector('we-title > span').textContent = val.data.string; } if (val['drop-near']) { @@ -800,17 +822,57 @@ var SnippetEditor = Widget.extend({ } var optionName = val.option; - var option = new (options.registry[optionName] || options.Class)( - this, - val.$el.children(), - val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target, - this.$el, - Object.assign({ - optionName: optionName, - snippetName: this.getName(), - }, val.data), - this.options - ); + let option; + if (val.isOwl) { + // TODO: @owl-options remove when legacy is completely converted. + this._hasOwlOptions = true; + this.name = val.data.string || this.getName(); + option = Object.assign({}, registry.category("snippet_options").get(optionName)); + const optionClass = option.Class || SnippetOption; + option.instance = new (optionClass)({ + editor: this, + $target: val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target, + $overlay: this.$el, + data: { + optionName, + snippetName: this.getName(), + ...val.data, + }, + options: this.options, + callbacks: { + requestUserValue: this._requestUserValue.bind(this), + cover: this.cover.bind(this), + coverUpdate: (overlayVisible) => this.trigger_up("cover_update", { overlayVisible }), + notifyOptions: (data) => this.trigger_up("option_update", data), + updateExtraTitle: (extra) => { this.extraTitle = extra }, + updateSnippetOptionVisibility: (show) => this.trigger_up("snippet_option_visibility_update", { show }), + } + }); + optionName = (optionClass).name + option.isOwl = true; + option.renderingComponent ??= (optionClass).defaultRenderingComponent; + option.renderingComponent.components = Object.fromEntries(registry.category("snippet_widgets").getEntries()); + option.forceNoDeleteButton = option.instance.forceNoDeleteButton ?? (optionClass).forceNoDeleteButton; + if (option.Class?.displayOverlayOptions) { + option.displayOverlayOptions = true; + } + if (option.Class?.isTopOption) { + option.isTopOption = true; + } + } else { + return; + option = new (options.registry[optionName] || options.Class)( + this, + val.$el.children(), + val.base_target ? this.$target.find(val.base_target).eq(0) : this.$target, + this.$el, + Object.assign({ + optionName: optionName, + snippetName: this.getName(), + }, val.data), + this.options + ); + } var key = optionName || uniqueId("option"); if (this.styles[key]) { // If two snippet options use the same option name (and so use @@ -818,7 +880,7 @@ var SnippetEditor = Widget.extend({ // ID (TODO improve) key = uniqueId(key); } - this.styles[key] = option; + this.styles[key] = option.isOwl ? option.instance : option; option.__order = i++; if (option.forceNoDeleteButton) { @@ -834,6 +896,11 @@ var SnippetEditor = Widget.extend({ this.forceDuplicateButton = true; } + if (val.isOwl) { + this.snippetOptions.push(markRaw(option)); + option.name = key; + return option.instance.willStart(); + } return option.appendTo(document.createDocumentFragment()); }); @@ -852,6 +919,10 @@ var SnippetEditor = Widget.extend({ const options = sortBy(Object.values(this.styles), "__order"); const firstOptions = []; options.forEach(option => { + // TODO: @owl-options Properly handle option position + if (option.isOwl) { + return; + } if (option.isTopOption) { if (option.isTopFirstOption) { firstOptions.push(option); @@ -1519,7 +1590,9 @@ var SnippetEditor = Widget.extend({ // another snippet, fill the gap left in the starting snippet. if (this.dragState.mobileOrder !== undefined && this.$target[0].parentNode !== this.dragState.startingParent) { - ColumnLayoutMixin._fillRemovedItemGap(this.dragState.startingParent, this.dragState.mobileOrder); + // TODO: @owl-options this is a bit ugly, maybe this method + // should be moved into an util. + ColumnLayoutUtils._fillRemovedItemGap(this.dragState.startingParent, this.dragState.mobileOrder); } this.$target.trigger('content_changed'); @@ -1580,6 +1653,13 @@ var SnippetEditor = Widget.extend({ * @param {OdooEvent} ev */ _onOptionUpdate: function (ev) { + // TODO: @owl-options: when removing trigger_up, adapt calls to this. + if (ev._stopped) { + return; + } + if (!ev.stopPropagation) { + ev.stopPropagation = () => ev._stopped = true; + } var self = this; // If multiple option names are given, we suppose it should not be @@ -1649,6 +1729,29 @@ var SnippetEditor = Widget.extend({ ev.stopPropagation(); } }, + /** + * Requests the UserValue of another option + * + * @param {string} name - the name of the userValue / Component + * @param {boolean} allowParentOption - if true, the request will be propagated to the parent + * @returns {import('./snippets.options.js').UserValue} the UserValue of the requested option + */ + _requestUserValue({ name, allowParentOption }) { + for (const key of Object.keys(this.styles)) { + const widget = this.styles[key].findWidget(name); + if (widget) { + return widget; + } + } + if (!allowParentOption) { + return null; + } + const parent = this.getParent(); + if (parent._requestUserValue) { + return parent._requestUserValue({ name, allowParentOption }); + } + return null; + }, /** * Called when the 'mouse wheel' is used when hovering over the overlay. * Disable the pointer events to prevent page scrolling from stopping. @@ -1790,6 +1893,59 @@ var SnippetEditor = Widget.extend({ }, }); +class SnippetOptionsManager extends Component { + static props = { + editors: { type: Object }, + enabledEditors: { type: Array }, + onOptionMounted: { type: Function }, + activateTab: { type: Function }, + execWithLoadingEffect: { type: Function }, + }; + static template = "web_editor.SnippetOptionsManager"; + + setup() { + useEffect( + () => { + this.props.execWithLoadingEffect(async () => { + const editorToEnable = this.props.enabledEditors[0]; + let enabledOptions; + if (editorToEnable) { + enabledOptions = await editorToEnable.toggleOptions(true); + if (!enabledOptions) { + // As some options can only be generated using JavaScript + // (e.g. 'SwitchableViews'), it may happen at this point + // that the overlay is activated even though there are no + // options. That's why we disable the overlay if there are + // no options to enable. + editorToEnable.toggleOverlay(false); + } + } + if (enabledOptions) { + this.props.activateTab(SnippetsMenu.tabs.OPTIONS); + } + }); + }, + () => { + // The compute dependencies can never return an empty array or + // the effect will not be reapplied, and therefore the tab will + // not change to blocks. + if (this.props.enabledEditors.length) { + return this.props.enabledEditors; + } else { + return [false]; + } + } + ); + } + + getEditors() { + return [...this.props.editors].sort((e1, e2) => { + return this.props.enabledEditors.indexOf(e1) < this.props.enabledEditors.indexOf(e2); + }); + } +} + + /** * Management of drag&drop menu and snippet related behaviors in the page. */ @@ -1856,11 +2012,13 @@ class SnippetsMenu extends Component { static template = "web_editor.SnippetsMenu"; - static components = { Toolbar, LinkTools }; + static components = { Toolbar, LinkTools, SnippetOptionsManager }; setup() { super.setup(...arguments); this.options = Object.assign({}, this.props.options); + this.options.snippetEditionRequest = this.snippetEditionRequest.bind(this); + this.options.optionUpdate = this._snippetOptionUpdate.bind(this); this.$body = $((this.options.document || document).body); this.customEvents = SnippetsMenu.custom_events; this.tabs = SnippetsMenu.tabs; @@ -1889,7 +2047,7 @@ class SnippetsMenu extends Component { this.snippetsAreaRef = useRef("snippets-area"); this.snippetEditors = []; - this._enabledEditorHierarchy = []; + this.state.enabledEditorHierarchy = []; this._mutex = this.options.mutex; @@ -1987,6 +2145,31 @@ class SnippetsMenu extends Component { useBus(this.props.bus, "CLEAN_FOR_SAVE", ({ detail }) => { detail.proms.push(this.cleanForSave()); }); + + useSubEnv({ + activateSnippet: this._activateSnippet.bind(this), + blockPreviewOverlays: this._onBlockPreviewOverlays.bind(this), + cloneSnippet: this._cloneSnippet.bind(this), + cleanUI: this._cleanUI.bind(this), + findSnippetTemplate: (data) => this._onFindSnippetTemplate.call(this, { data }), + getSnippetVersions: (data) => this._onGetSnippetVersions.call(this, { data }), + requestSave: (data) => this._onSaveRequest.call(this, { data }), + removeSnippet: this._onRemoveSnippet.bind(this), + snippetThumbnailUrlRequest: (data) => this._onSnippetThumbnailURLRequest.call(this, { data }), + isElementSelected: (data) => this._onIsElementSelected.call(this, { data }), + hideOverlay: this._onHideOverlay.bind(this), + unblockPreviewOverlays: this._onUnblockPreviewOverlays.bind(this), + updateInvisibleDOM: this._onUpdateInvisibleDom.bind(this), + userValueWidgetOpening: this._onUserValueWidgetOpening.bind(this), + userValueWidgetClosing: this._onUserValueWidgetClosing.bind(this), + snippetEditionRequest: this._execWithLoadingEffect.bind(this), + }); + this.options.env = this.env; + // If multiple SnippetOptionsComponent are mounted at the same time, + // only compute their state once per tick instead of once per option. + this.onOptionMounted = batched(() => { + this._onOptionMounted(); + }); } /** * By default, the SnippetCache is only invalidated when the browser is @@ -2281,6 +2464,7 @@ class SnippetsMenu extends Component { this.tooltips.dispose(); this.buttonTooltips.forEach(tooltip => tooltip.dispose()); options.clearServiceCache(); + clearServiceCache(); options.clearControlledSnippets(); if (this.$body[0].ownerDocument !== this.ownerDocument) { this.$body.off('.snippets_menu'); @@ -2882,16 +3066,16 @@ class SnippetsMenu extends Component { } resolve(null); }).then(async editorToEnable => { - if (!previewMode && this._enabledEditorHierarchy[0] === editorToEnable - || ifInactiveOptions && this._enabledEditorHierarchy.includes(editorToEnable)) { + if (!previewMode && this.state.enabledEditorHierarchy[0] === editorToEnable + || ifInactiveOptions && this.state.enabledEditorHierarchy.includes(editorToEnable)) { return editorToEnable; } if (!previewMode) { - this._enabledEditorHierarchy = []; + this.state.enabledEditorHierarchy = []; let current = editorToEnable; while (current && current.$target) { - this._enabledEditorHierarchy.push(current); + this.state.enabledEditorHierarchy.push(markRaw(current)); current = current.getParent(); } } @@ -2916,34 +3100,26 @@ class SnippetsMenu extends Component { if (editorToEnable) { editorToEnable.toggleOverlay(true, previewMode); if (!previewMode && !editorToEnable.displayOverlayOptions) { - const parentEditor = this._enabledEditorHierarchy.find(ed => ed.displayOverlayOptions); + const parentEditor = this.state.enabledEditorHierarchy.find(ed => ed.displayOverlayOptions); if (parentEditor) { parentEditor.toggleOverlay(true, previewMode); } } - customize$Elements = await editorToEnable.toggleOptions(true); + // TODO: this code is handled by SnippetOptionsManager now + // customize$Elements = await editorToEnable.toggleOptions(true); } else { for (const editor of this.snippetEditors) { if (editor.isSticky()) { editor.toggleOverlay(true, false); - customize$Elements = await editor.toggleOptions(true); + // customize$Elements = await editor.toggleOptions(true); + this.state.enabledEditorHierarchy.push(editor); } } } - - if (!previewMode) { - // As some options can only be generated using JavaScript - // (e.g. 'SwitchableViews'), it may happen at this point - // that the overlay is activated even though there are no - // options. That's why we disable the overlay if there are - // no options to enable. - if (editorToEnable && !customize$Elements) { - editorToEnable.toggleOverlay(false); - } + if (!editorToEnable || this.state.enabledEditorHierarchy.lenght === 0) { this._updateRightPanelContent({ - content: customize$Elements || [], - tab: customize$Elements ? this.tabs.OPTIONS : this.tabs.BLOCKS, - }); + tab: this.tabs.BLOCKS + }) } return editorToEnable; @@ -3229,6 +3405,40 @@ class SnippetsMenu extends Component { }); $styles.addClass('d-none'); + // TODO: @owl-options Rename this property when all options have been converted. + this.templateOptions.push(...this.getOptions().map(([optionID, option]) => { + const selector = option.selector; + const exclude = option.exclude || ""; + const excludeParent = optionID === "so_content_addition" ? snippetAdditionDropIn : ""; + const target = option.target; + const noCheck = option.noCheck; + const optionDef = { + "option": optionID, + "base_selector": selector, + "base_exclude": exclude, + "base_target": target, + "selector": this._computeSelectorFunctions({selector, exclude, target, noCheck}), + isOwl: true, + "drop-near": option.dropNear && this._computeSelectorFunctions({ + selector: option.dropNear, + noCheck, + isChildren: true, + excludeParent, + forDrop: true + }), + "drop-in": option.dropIn && this._computeSelectorFunctions({ + selector: option.dropIn, + noCheck, + forDrop: true, + }), + "drop-exclude-ancestor": option.dropExcludeAncestor, + "drop-lock-within": option.dropLockWithin, + "data": option.data || {}, + }; + selectors.push(optionDef.selector); + return optionDef; + })); + globalSelector.closest = function ($from) { var $temp; var $target; @@ -3346,6 +3556,15 @@ class SnippetsMenu extends Component { this._disableUndroppableSnippets(); } + /** + * Returns all the options from the registry. Allows modules to override + * this to filter out options that don't belong to them. + * + * @return {[string, Object][]} + */ + getOptions() { + return registry.category("snippet_options").getEntries(); + } /** * Creates a snippet editor to associated to the given snippet. If the given * snippet already has a linked snippet editor, the function only returns @@ -3427,6 +3646,7 @@ class SnippetsMenu extends Component { const canDrop = ($els) => [...$els].some((el) => checkSanitize(el) && isVisible(el)); var check = false; + // TODO: @owl-options Add Owl Options here. self.templateOptions.forEach((option, k) => { if (check || !($snippetBody.is(option.base_selector) && !$snippetBody.is(option.base_exclude))) { return; @@ -3846,7 +4066,7 @@ class SnippetsMenu extends Component { * the new content of the customizePanel * @param {this.tabs.VALUE} [tab='blocks'] - the tab to select */ - _updateRightPanelContent({content, tab, ...options}) { + _updateRightPanelContent({tab, ...options}) { this._hideTooltips(); this._closeWidgets(); @@ -3855,30 +4075,15 @@ class SnippetsMenu extends Component { tab = SnippetsMenu.tabs.OPTIONS; } - this.state.currentTab = tab || SnippetsMenu.tabs.BLOCKS; - - if (this._$toolbarContainer) { - this._$toolbarContainer[0].remove(); - } this.state.showToolbar = false; this._$toolbarContainer = null; - if (content) { - // The toolbar component will be hidden or shown by state.showToolbar - // as it is an OWL Component, OWL is in charge of the HTML for that - // component. So we do not want to remove it. - // TODO: This should be improved when SnippetEditor / SnippetOptions - // are converted to OWL. - while (this.customizePanel.firstChild?.id !== "o_we_editor_toolbar_container") { - this.customizePanel.removeChild(this.customizePanel.firstChild); - } - $(this.customizePanel).prepend(content); - if (this.state.currentTab === this.tabs.OPTIONS && !options.forceEmptyTab) { - this._addToolbar(); - } + if (this.state.currentTab === this.tabs.OPTIONS && !options.forceEmptyTab) { + this._addToolbar(); } if (options.forceEmptyTab) { this.state.showToolbar = false; } + this.state.currentTab = tab; } /** * Scrolls to given snippet. @@ -4051,19 +4256,40 @@ class SnippetsMenu extends Component { async _snippetOptionUpdate() { // Only update editors whose DOM target is still inside the document // as a top option may have removed currently-enabled child items. - const editors = this._enabledEditorHierarchy.filter(editor => !!editor.$target[0].closest('body')); + const editors = this.state.enabledEditorHierarchy.filter(editor => !!editor.$target[0].closest('body')); await Promise.all(editors.map(editor => editor.updateOptionsUI())); await Promise.all(editors.map(editor => editor.updateOptionsUIVisibility())); // Always enable the deepest editor whose DOM target is still inside // the document. - if (editors[0] !== this._enabledEditorHierarchy[0]) { + if (editors[0] !== this.state.enabledEditorHierarchy[0]) { // No awaiting this as the mutex is currently locked here. this._activateSnippet(editors[0].$target); } } /** + * Clones an existing snippet + * + * @param {jQuery} $snippet - the snippet to copy + */ + async _cloneSnippet($snippet) { + const editor = await this._createSnippetEditor($snippet); + await editor.clone(); + } + /** + * Asks options to clean their UI elements present in the DOM + * + * @param {HTMLElement} targetEl + * @returns {Promise} + */ + async _cleanUI(targetEl) { + const targetEditors = this.snippetEditors.filter(editor => { + return targetEl.contains(editor.$target[0]); + }); + await Promise.all(targetEditors.map(editor => editor.cleanUI())); + } + /** * @private */ _allowInTranslationMode($snippet) { @@ -4174,8 +4400,7 @@ class SnippetsMenu extends Component { */ async _onCloneSnippet(ev) { ev.stopPropagation(); - const editor = await this._createSnippetEditor(ev.data.$snippet); - await editor.clone(); + await this._cloneSnippet(ev.data.$snippet); if (ev.data.onSuccess) { ev.data.onSuccess(); } @@ -4187,12 +4412,7 @@ class SnippetsMenu extends Component { * @param {OdooEvent} ev */ _onCleanUIRequest(ev) { - const targetEditors = this.snippetEditors.filter(editor => { - return ev.data.targetEl.contains(editor.$target[0]); - }); - Promise.all(targetEditors.map(editor => editor.cleanUI())).then(() => { - ev.data.onSuccess(); - }); + this._cleanUI(ev.data.targetEl).then(ev.data.onSuccess); } /** * Called when a child editor asks to deactivate the current snippet @@ -4944,6 +5164,29 @@ class SnippetsMenu extends Component { return this.props.trigger_up(ev); } } + /** + * When a new option is mounted, update all existing editors to reflect the + * potential new values being computed/asked. + */ + async _onOptionMounted() { + // Delay the mutex until all post drop actions are done. It cannot be + // awaited inside the mutex because callPostSnippetDrop locks the mutex + // and it needs to be awaited because callPostSnippetDrop also creates + // editors, but it does so outside the mutex. However, calling + // updateOptionsUI while editors are being created creates race + // conditions + if (this.postSnippetDropPromise) { + await this.postSnippetDropPromise; + } + this.execWithLoadingEffect(async () => { + await Promise.all(this.state.enabledEditorHierarchy.map(editor => editor.updateOptionsUI())); + await Promise.all(this.state.enabledEditorHierarchy.map(editor => editor.updateOptionsUIVisibility())); + }); + } + + snippetEditionRequest({ exec, optionsLoader }) { + this._execWithLoadingEffect(exec, optionsLoader ? "both" : true); + } /** * @private * @param {OdooEvent} ev @@ -5207,6 +5450,9 @@ class SnippetsMenu extends Component { } snippet.renaming = false; } + activateTab(tab) { + this._updateRightPanelContent({tab}); + } } export default { diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 684db286e68c3..91a6f697dc1ec 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -1,16 +1,22 @@ /** @odoo-module **/ import { attachComponent } from "@web/legacy/utils"; +import { uniqueId } from "@web/core/utils/functions"; +import { registry } from "@web/core/registry"; +import { user } from "@web/core/user"; import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; -import { throttleForAnimation, debounce } from "@web/core/utils/timing"; +import { KeepLast } from "@web/core/utils/concurrency"; +import { useSortable } from "@web/core/utils/sortable_owl"; +import { camelToKebab } from "@web/core/utils/strings"; +import { useThrottleForAnimation, debounce } from "@web/core/utils/timing"; import { clamp } from "@web/core/utils/numbers"; import { scrollTo } from "@web/core/utils/scrolling"; import Widget from "@web/legacy/js/core/widget"; import { ColorPalette } from "@web_editor/js/wysiwyg/widgets/color_palette"; import weUtils from "@web_editor/js/common/utils"; import * as gridUtils from "@web_editor/js/common/grid_layout_utils"; -import {ColumnLayoutMixin} from "@web_editor/js/common/column_layout_mixin"; +import { ColumnLayoutMixin, ColumnLayoutUtils } from "@web_editor/js/common/column_layout_mixin"; const { normalizeColor, getBgImageURL, @@ -41,6 +47,26 @@ import { } from '@web/core/utils/colors'; import { renderToElement } from "@web/core/utils/render"; import { rpc } from "@web/core/network/rpc"; +import snippetsOptionsLegacy from "./snippets.options.legacy"; +import { + Component, + markup, + onMounted, + onWillStart, + onWillUpdateProps, + onPatched, + onWillDestroy, + onWillUnmount, + reactive, + useEffect, + useChildSubEnv, + useRef, + useState, + useSubEnv, +} from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { registerOption } from "./snippets.registry"; const preserveCursor = OdooEditorLib.preserveCursor; const { DateTime } = luxon; @@ -49,13 +75,18 @@ let _serviceCache = { orm: {}, rpc: {}, }; -const clearServiceCache = () => { +export const clearServiceCache = () => { _serviceCache = { orm: {}, rpc: {}, }; }; +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +// TODO: This should be removed when all options are propperly migrated to OWL +const SnippetOptionWidget = snippetsOptionsLegacy.SnippetOptionWidget; +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + // Regex definitions to apply speed modification in SVG files // Note : These regex patterns are duplicated on the server side for // background images that are part of a CSS rule "background-image: ...". The @@ -70,31 +101,43 @@ const SVG_DUR_TIMECOUNT_VAL_REGEX = /(?\sdur="\s*)(?(?:\d+(?:\.\d+)?)|(?:\.\d+))(?h|min|ms|s)?\s*"/gm; const CSS_ANIMATION_RATIO_REGEX = /(--animation_ratio: (?\d*(\.\d+)?));/m; /** - * Caches rpc/orm service - * @param {Function} service - * @param {...any} args - * @returns + * Caches the calls to a service. + * + * @param {Object} [env] - Object to find services in, if undefined, assume RPC + * @param {String} [serviceName] - service name, if empty, assume RPC + * @param {String[]} [exclude] - methodNames to exclude from the cache + * + * @returns {Object|Function} if RPC, a cached function, else a Proxy that + * caches the call to the services. */ -function serviceCached(service) { - const cache = _serviceCache; - return Object.assign(Object.create(service), { - call() { - // FIXME - const serviceName = Object.prototype.hasOwnProperty.call(service, "call") - ? "orm" - : "rpc"; - const cacheId = JSON.stringify(arguments); - if (!cache[serviceName][cacheId]) { - cache[serviceName][cacheId] = - serviceName == "rpc" ? service(...arguments) : service.call(...arguments); - } - return cache[serviceName][cacheId]; - }, - }); +export function serviceCached(env, serviceName, exclude = []) { + function cachedCall(func, args, cache) { + const cacheId = JSON.stringify(args); + if (!cache[cacheId]) { + cache[cacheId] = func(...args); + } + return cache[cacheId]; + } + + if (!env || !serviceName || serviceName === "rpc") { + return (...args) => cachedCall(rpc, args, _serviceCache["rpc"]); + } else { + const service = env.services[serviceName]; + if (!_serviceCache[serviceName]) { + _serviceCache[serviceName] = {}; + } + return new Proxy(service, { + get: (target, prop, receiver) => { + if (typeof target[prop] === "function" && !exclude.includes(prop)) { + return (...args) => cachedCall(target[prop].bind(target), args, _serviceCache[serviceName]); + } + } + }); + } } // Outdated snippets whose alert has been discarded. const controlledSnippets = new Set(); -const clearControlledSnippets = () => controlledSnippets.clear(); +export const clearControlledSnippets = () => controlledSnippets.clear(); /** * @param {HTMLElement} el * @param {string} [title] @@ -188,27 +231,87 @@ async function _buildImgElement(src) { 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); +const _buildSvgElementCache = {}; +async function buildSvgElement(src) { + if (!(src in _buildSvgElementCache)) { + _buildSvgElementCache[src] = (async () => { + let text; + try { + const response = await window.fetch(src); + text = await response.text(); + } catch { + // In some tours, the tour finishes before the fetch is done + // and when a tour is finished, the python side will ask the + // browser to stop loading resources. This causes the fetch + // to fail and throw an error which crashes the test even + // though it completed successfully. + // So return an empty SVG to ensure everything completes + // correctly. + text = ""; + } + return markup(text); + })(); + } + const svgMarkup = await _buildSvgElementCache[src]; + return svgMarkup; +} + +class WeTitle extends Component { + static template = "web_editor.WeTitle"; + static props = { + title: { type: String, optional: true }, + class: { type: String, optional: true }, + style: { type: String, optional: true }, + "*": { optional: true }, + }; +} - if (options && options.childNodes) { - options.childNodes.forEach(node => rowEl.appendChild(node)); +export class WeRow extends Component { + static template = "web_editor.WeRow"; + static props = { + class: { type: String, optional: true }, + fontFamily: { type: String, optional: true }, + slots: { type: Object, optional: true }, + title: { type: String, optional: true }, + tooltip: { type: String, optional: true }, + "*": { optional: true }, + }; + static components = { WeTitle }; + setup() { + /** @type {Object.} */ + this.userValues = {}; + this.state = useState({ + show: false, + }); + this.env.registerLayoutElement({ state: this.state, userValues: this.userValues }, true); + useChildSubEnv({ + registerUserValue: (userValue) => { + this.userValues[userValue.id] = userValue; + this.env.registerUserValue(userValue); + }, + unregisterUserValue: (userValue) => { + delete this.userValues[userValue.id]; + this.env.unregisterUserValue(userValue); + }, + }); } +} - return groupEl; +registry.category("snippet_widgets").add("WeRow", WeRow); + +export class WeCollapse extends WeRow { + static template = "web_editor.WeCollapse"; + + toggle() { + this.state.active = !this.state.active; + } } + +registry.category("snippet_widgets").add("WeCollapse", WeCollapse); + /** + * TODO: @owl-options remove when done. * Build the correct DOM for a we-collapse element. * * @param {string} [title] - @see _buildElement @@ -216,7 +319,7 @@ function _buildRowElement(title, options) { * @param {HTMLElement[]} [options.childNodes] * @returns {HTMLElement} */ -function _buildCollapseElement(title, options) { +export function _buildCollapseElement(title, options) { const groupEl = _buildElement('we-collapse', title, options); const titleEl = groupEl.querySelector('we-title'); @@ -269,20 +372,6 @@ function createPropertyProxy(obj, propertyName, value) { }, }); } -/** - * Creates and registers a UserValueWidget by tag-name - * - * @param {string} widgetName - * @param {SnippetOptionWidget|UserValueWidget|null} parent - * @param {string} title - * @param {Object} options - * @returns {UserValueWidget} - */ -function registerUserValueWidget(widgetName, parent, title, options, $target) { - const widget = new userValueWidgetsRegistry[widgetName](parent, title, options, $target); - parent.registerSubWidget(widget); - return widget; -} //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: @@ -292,42 +381,86 @@ 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: { +const UserValueWidget = Widget; + +export class UserValue { + static custom_events = { 'user_value_update': '_onUserValueNotification', - }, + }; /** * @constructor + * @param {string} id + * @param {Object} state + * @param {Object} env + * @param {SnippetOption} option + * @param {Object} data + */ + constructor(id, state, env, option, data) { + this.id = id; + this.option = option; + this.env = env; + // TODO: @owl-option maybe we should take the opportunity to remove this. + this.$target = env.$target; + /** @type {UserValue[]} */ + this._subValues = {}; + this.mounted = false; + this.textContent = ""; + this._state = state; + this._state.preview = false; + this._state.active = false; + this._state.show = true; + this._data = data; + } + /** + * Returns the value that is currently in the state. This allows components + * to react to state changes, and is different than what a SnippetOption + * would need. */ - init: function (parent, title, options, $target) { - this._super(...arguments); - this.title = title; - this.options = options; - this._userValueWidgets = []; - this._value = ''; - this.$target = $target; - }, + get value() { + return this._state.value; + } + + set value(value) { + this._state.value = value; + } /** - * @override + * Returns whether the component that posses this state should be visible + * or not. Cannot be set directly, use {@link toggleVisibility} instead. + */ + get show() { + return this._state.show; + } + /** + * Returns whether sub widgets should or shouldn't be shown. Typically in + * the case of a list, we do not want to show all buttons at all time, so + * instead they will only be shown when this value is true. + * + * @type {boolean} + */ + get opened() { + return this._state.opened; + } + /** + * Gives the opportunity for values to prepare asynchronous content. + * E.g. fetch external data. + */ + async start() { + this.started = true; + } + /** + * @TODO: @owl-options: This should be moved into the component */ async willStart() { await this._super(...arguments); - if (this.options.dataAttributes.img) { - this.illustrationEl = await _buildImgElement(this.options.dataAttributes.img); - } else if (this.options.dataAttributes.icon) { - this.illustrationEl = document.createElement('i'); - this.illustrationEl.classList.add('fa', this.options.dataAttributes.icon); - } if (this.options.dataAttributes.reload) { this.options.dataAttributes.noPreview = "true"; } - }, + } /** - * @override + * @TODO: @owl-options: This should be moved into the component */ - _makeDescriptive: function () { + _makeDescriptive() { const $el = this._super(...arguments); const el = $el[0]; _addTitleAndAllowedAttributes(el, this.title, this.options); @@ -339,11 +472,11 @@ const UserValueWidget = Widget.extend({ el.appendChild(this.containerEl); return $el; - }, + } /** - * @override + * @TODO: @owl-options: This should be moved into the component */ - async start() { + async old_start() { await this._super(...arguments); if (this.el.classList.contains('o_we_img_animate')) { @@ -357,9 +490,9 @@ const UserValueWidget = Widget.extend({ this.$el.on('mouseenter.img_animate', buildImgExtensionSwitcher('png', 'gif')); this.$el.on('mouseleave.img_animate', buildImgExtensionSwitcher('gif', 'png')); } - }, + } /** - * @override + * @TODO: @owl-options: This should be moved into the component */ destroy() { // Check if $el exists in case the widget is destroyed before it has @@ -370,7 +503,7 @@ const UserValueWidget = Widget.extend({ this.$el.off('.img_animate'); } this._super(...arguments); - }, + } //-------------------------------------------------------------------------- // Public @@ -379,15 +512,8 @@ const UserValueWidget = Widget.extend({ /** * 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; - } - if (!this.el.classList.contains('o_we_widget_opened')) { + close() { + if (!this._state.opened) { // Small optimization: it would normally not matter asking to // remove a class of an element if it does not already have it but // in this case we do more: we trigger_up an event and ask to close @@ -397,22 +523,25 @@ const UserValueWidget = Widget.extend({ // instructions being done at each click in the editor. return; } - this.trigger_up('user_value_widget_closing'); - this.el.classList.remove('o_we_widget_opened'); - this._userValueWidgets.forEach(widget => widget.close()); - }, + this.env.userValueWidgetClosing(); + this._state.opened = false; + Object.values(this._subValues).forEach(widget => widget.close()); + } /** - * Simulates the correct event on the element to make it active. + * Triggers a value change in non preview mode. + * Should be overridden by Sub-classes to properly reflect what enabling + * their component means. */ enable() { - this.$el.click(); - }, + this.notifyValueChange(false); + } + // TODO: @owl-options: rename this to findUserValue /** * @param {string} name * @returns {UserValueWidget|null} */ - findWidget: function (name) { - for (const widget of this._userValueWidgets) { + findWidget(name) { + for (const widget of Object.values(this._subValues)) { if (widget.getName() === name) { return widget; } @@ -422,7 +551,7 @@ const UserValueWidget = Widget.extend({ } } return null; - }, + } /** * Focus the main focusable element of the widget. */ @@ -431,7 +560,7 @@ const UserValueWidget = Widget.extend({ if (el) { el.focus(); } - }, + } /** * Returns the value that the widget would hold if it was active, by default * the internal value it holds. @@ -439,9 +568,9 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] * @returns {string} */ - getActiveValue: function (methodName) { - return this._value; - }, + getActiveValue(methodName) { + return this.value; + } /** * Returns the default value the widget holds when inactive, by default the * first "possible value". @@ -449,25 +578,25 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] * @returns {string} */ - getDefaultValue: function (methodName) { + getDefaultValue(methodName) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; return possibleValues && possibleValues[0] || ''; - }, + } /** * @returns {string[]} */ - getDependencies: function () { + getDependencies() { 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; - }, + getMethodsNames() { + return [...this._methodsNames.values()]; + } /** * Returns the option parameters associated to the widget (for a given * method name or not). Most are loaded with @see loadMethodsData. @@ -475,7 +604,7 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] * @returns {Object} */ - getMethodsParams: function (methodName) { + getMethodsParams(methodName) { const params = Object.assign({}, this._methodsParams); if (methodName) { params.possibleValues = params.optionsPossibleValues[methodName] || []; @@ -483,13 +612,13 @@ const UserValueWidget = Widget.extend({ params.defaultValue = this.getDefaultValue(methodName); } return params; - }, + } /** * @returns {string} empty string if no name is used by the widget */ - getName: function () { + getName() { 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 @@ -498,62 +627,69 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] * @returns {string} */ - getValue: function (methodName) { + getValue(methodName) { const isActive = this.isActive(); - if (!methodName || !this._methodsNames.includes(methodName)) { + if (!methodName || !this._methodsNames.has(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; - }, + isActive() { + return this.value && this.value !== NULL_ID; + } /** * Indicates if the widget can contain sub user value widgets or not. * * @returns {boolean} */ - isContainer: function () { + isContainer() { 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'); - }, + isPreviewed() { + // const focusEl = document.activeElement; + // TODO: @owl-options: implement this on input values + // if (focusEl && focusEl.tagName === 'INPUT' + // && (this.el === focusEl || this.el.contains(focusEl))) { + // return true; + // } + return this._state.preview; + } /** * Loads option method names and option method parameters. * * @param {string[]} validMethodNames * @param {Object} extraParams */ - loadMethodsData: function (validMethodNames, extraParams) { - this._methodsNames = []; + loadMethodsData(validMethodNames, extraParams) { + this._validMethodNames = validMethodNames; + this._methodsNames = new Set(); this._methodsParams = Object.assign({}, extraParams); this._methodsParams.optionsPossibleValues = {}; this._dependencies = []; this._triggerWidgetsNames = []; this._triggerWidgetsValues = []; - for (const key in this.el.dataset) { - const dataValue = this.el.dataset[key].trim(); + for (const key in this._data) { + // Ignore values set to false or undefined but not empty strings. + if (this._data[key] === false || this._data[key] === undefined) { + continue; + } + + const dataValue = this._data[key].trim(); if (key === 'dependencies') { this._dependencies.push(...dataValue.split(/\s*,\s*/g)); @@ -562,47 +698,26 @@ const UserValueWidget = Widget.extend({ } else if (key === 'triggerValue') { this._triggerWidgetsValues.push(...dataValue.split(/\s*,\s*/g)); } else if (validMethodNames.includes(key)) { - this._methodsNames.push(key); + this._methodsNames.add(key); this._methodsParams.optionsPossibleValues[key] = dataValue.split(/\s*\|\s*/g); } else { this._methodsParams[key] = dataValue; } } - this._userValueWidgets.forEach(widget => { - const inheritedParams = Object.assign({}, 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; - } + // TODO: @owl-options Sort the method names or fix this. // Method names come from the widget's dataset whose keys' order cannot // be relied on. We explicitely sort them by alphabetical order allowing // consistent behavior, while relying on order for such methods should // not be done when possible (the methods should be independent from // each other when possible). - this._methodsNames.sort(); - }, + //this._methodsNames.sort(); + } /** * @param {boolean} [previewMode=false] * @param {boolean} [isSimulatedEvent=false] */ - notifyValueChange: function (previewMode, isSimulatedEvent) { + notifyValueChange(previewMode, isSimulatedEvent) { // In the case we notify a change update, force a preview update if it // was not already previewed const isPreviewed = this.isPreviewed(); @@ -614,6 +729,7 @@ const UserValueWidget = Widget.extend({ previewMode: previewMode || false, isSimulatedEvent: !!isSimulatedEvent, }; + // @owl-options: Could we change this? // 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 @@ -625,29 +741,54 @@ const UserValueWidget = Widget.extend({ // 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'); + data.prepare = () => this._state.preview = true; } else if (previewMode === 'reset') { - data.prepare = () => this.el.classList.remove('o_we_preview'); + data.prepare = () => this._state.preview = false; } - this.trigger_up('user_value_update', data); - }, + this.userValueNotification(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'); - }, + this.env.userValueWidgetOpening(this); + this._state.opened = true; + } /** - * Adds the given widget to the known list of user value sub-widgets (useful - * for container widgets). + * Adds the given userValue to the list of sub values (useful for container + * components). * - * @param {UserValueWidget} widget + * @param {UserValue} userValue */ - registerSubWidget: function (widget) { - this._userValueWidgets.push(widget); - }, + registerUserValue(userValue) { + this._subValues[userValue.id] = (userValue); + const inheritedParams = Object.assign({}, this._methodsParams); + inheritedParams.optionsPossibleValues = null; + userValue.loadMethodsData(this._validMethodNames, inheritedParams); + const subMethodsNames = userValue.getMethodsNames(); + const subMethodsParams = userValue.getMethodsParams(); + + for (const methodName of subMethodsNames) { + if (!this._methodsNames.has(methodName)) { + this._methodsNames.add(methodName); + this._methodsParams.optionsPossibleValues[methodName] = []; + } + for (const subPossibleValue of subMethodsParams.optionsPossibleValues[methodName]) { + if (!this._methodsParams.optionsPossibleValues[methodName].includes(subPossibleValue)) { + this._methodsParams.optionsPossibleValues[methodName].push(subPossibleValue); + } + } + } + } + /** + * Removes the given userValue from the list of sub values. + * + * @param {UserValue} userValue + */ + unregisterUserValue(userValue) { + delete this._subValues[userValue.id]; + } /** * Sets the user value that the widget should currently hold, for the * given method name. @@ -662,23 +803,24 @@ const UserValueWidget = Widget.extend({ * @param {string} [methodName] */ async setValue(value, methodName) { - this._value = value; - this.el.classList.remove('o_we_preview'); - }, + this._state.preview = false; + this.value = value; + } /** * @param {boolean} show */ - toggleVisibility: function (show) { - let doFocus = false; - if (show) { - const wasInvisible = this.el.classList.contains('d-none'); - doFocus = wasInvisible && this.el.dataset.requestFocus === "true"; - } - this.el.classList.toggle('d-none', !show); - if (doFocus) { - this.focus(); - } - }, + toggleVisibility(show) { + this._state.show = show; + // let doFocus = false; + // if (show) { + // const wasInvisible = this.el.classList.contains('d-none'); + // doFocus = wasInvisible && this.el.dataset.requestFocus === "true"; + // } + // this.el.classList.toggle('d-none', !show); + // if (doFocus) { + // this.focus(); + // } + } //-------------------------------------------------------------------------- // Private @@ -692,15 +834,181 @@ const UserValueWidget = Widget.extend({ * @private * @returns {HTMLElement} */ - _getFocusableElement: function () { + _getFocusableElement() { return null; - }, + } + /** + * Allows container widgets to add additional data if needed. + * + * @private + * @param {Object} params + */ + userValueNotification(params) { + params.widget = this; + + if (!params.triggerWidgetsNames) { + params.triggerWidgetsNames = []; + } + params.triggerWidgetsNames.push(...this._triggerWidgetsNames); + + if (!params.triggerWidgetsValues) { + params.triggerWidgetsValues = []; + } + params.triggerWidgetsValues.push(...this._triggerWidgetsValues); + this.env.userValueNotification(params); + } +} + +export class UserValueComponent extends Component { + + static props = { + name: { type: String, optional: true }, + slots: { type: Object, optional: true }, + // Allow any prop as they will reference a method of SnippetOption + "*": {}, + }; + + static StateModel = UserValue; + + // This is an abstract component, it should not be used alone. + static template = ""; + + setup() { + const id = uniqueId("userValue"); + const state = useState({}); + const stateParams = this.getStateParams(); + this.state = new this.constructor.StateModel( + id, + state, + this.env, + this.env.snippetOption, + stateParams + ); + if (this.props.multiSequence) { + this.state.multiSequence = this.props.multiSequence; + } + + this.env.registerUserValue(this.state); + + if (this.constructor.isContainer) { + // If this component contains other UserValueComponents, they should + // not register to the options, but to this Component's state instead, so + // that this one's UserValue can handle their state, as they are + // heavily dependent on the parent's state. + useChildSubEnv({ + registerUserValue: (userValue) => { + this.state.registerUserValue(userValue); + if (this.env.isContained) { + this.env.registerUserValue(userValue); + } + }, + unregisterUserValue: (userValue) => { + this.state.unregisterUserValue(userValue); + }, + userValueNotification: (params) => { + this.state.userValueNotification(params); + }, + isContained: true, + }); + } + + onWillStart(async () => { + if (this.props.img) { + const src = this.props.img; + if (src.split(".").pop() === "svg") { + this.svg = await buildSvgElement(src); + } else { + this.img = this.props.img; + } + } + }); + // Track the changes to the textContent slot so that parent widgets can + // use it (e.g. WeSelect use the active value's textContent for its + // toggler). + this.textContentRef = useRef("text-content"); + useEffect( + () => { + const textContent = this.textContentRef.el?.textContent || ""; + if (textContent && textContent !== this.state.textContent) { + this.state.textContent = textContent; + } else if (!textContent) { + const fakeImgEl = this.textContentRef.el?.querySelector('.o_we_fake_img_item'); + if (fakeImgEl) { + this.state.fakeImg = markup(fakeImgEl.outerHTML); + } + } + }, + () => [this.textContentRef.el] + ); + + onWillDestroy(() => { + this.env.unregisterUserValue(this.state); + }); + } + /** + * Returns an object containing the classes defined in the props + * + * @returns {Object} + */ + getPropsClass() { + if (typeof this.props.class === "string") { + return { [this.props.class]: true }; + } + if (Array.isArray(this.props.class)) { + const classes = this.props.class.join(" "); + return { [classes]: this.props.class.length > 0 }; + } + if (typeof this.props.class === "object") { + return this.props.class; + } + } + /** + * Returns all the classes that should be applied on the root of the widget. + * + * @returns {Object} + */ + getAllClasses() { + return { + "active": this.state.active, + "o_we_preview": this.state.isPreviewed(), + "d-none": !this.state.show, + "o_we_widget_opened": this.state.opened, + "o_we_icon_button": this.img || this.svg || this.props.icon, + ...this.getPropsClass(), + }; + } + /** + * Sets all props as data attributes to match with existing tours. + * @TODO this should be removed or only shown when tours are actually + * activated. However, some SCSS styles are using attributes to style some + * buttons (e.g. BackgroundShape). + */ + getAllDataAttributes() { + const dataAttributes = {}; + for (const key of Object.getOwnPropertyNames(this.state._data)) { + const kebabName = key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); + dataAttributes[`data-${kebabName}`] = this.props[key]; + } + return dataAttributes; + } + /** + * Prepare the values that will be passed as parameters to the state. + * + * @private + * @return {Objectl} + */ + getStateParams() { + const propsCopy = { ...this.props }; + delete propsCopy.slots; + delete propsCopy.multiSequence; + return propsCopy; + } /** * @private * @param {OdooEvent|Event} * @returns {boolean} */ - _handleNotifierEvent: function (ev) { + _handleNotifierEvent(ev) { if (!ev) { return true; } @@ -712,7 +1020,7 @@ const UserValueWidget = Widget.extend({ ev.preventDefault(); } return true; - }, + } //-------------------------------------------------------------------------- // Handlers @@ -725,30 +1033,11 @@ const UserValueWidget = Widget.extend({ * @private * @param {OdooEvent|Event} [ev] */ - _onUserValueChange: function (ev) { + _onUserValueChange(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 = []; + this.state.notifyValueChange(false); } - 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. @@ -756,11 +1045,11 @@ const UserValueWidget = Widget.extend({ * @private * @param {OdooEvent|Event} [ev] */ - _onUserValuePreview: function (ev) { + _onUserValuePreview(ev) { if (this._handleNotifierEvent(ev)) { - this.notifyValueChange(true); + this.state.notifyValueChange(true); } - }, + } /** * Should be called when an user event on the widget indicates a value * reset. @@ -768,103 +1057,106 @@ const UserValueWidget = Widget.extend({ * @private * @param {OdooEvent|Event} [ev] */ - _onUserValueReset: function (ev) { + _onUserValueReset(ev) { if (this._handleNotifierEvent(ev)) { - this.notifyValueChange('reset'); + this.state.notifyValueChange('reset'); } - }, -}); + } +} -const ButtonUserValueWidget = UserValueWidget.extend({ - tagName: 'we-button', - events: { - 'click': '_onButtonClick', - 'click [role="button"]': '_onInnerButtonClick', - 'mouseenter': '_onUserValuePreview', - 'mouseleave': '_onUserValueReset', - }, +class ButtonUserValue extends UserValue { /** * @override */ - async willStart() { - await this._super(...arguments); - if (this.options.dataAttributes.activeImg) { - this.activeImgEl = await _buildImgElement(this.options.dataAttributes.activeImg); + async setValue(value, methodName) { + await super.setValue(...arguments); + let active = !!value; + if (methodName) { + if (!this._methodsNames.has(methodName)) { + return; + } + active = (this.getActiveValue(methodName) === value); } - }, + this._state.active = active; + // TODO: @owl-options: Some of this code is no longer used, it should + // probably be removed in its own commit. + // if (this.illustrationEl && this.activeImgEl) { + // this.illustrationEl.classList.toggle('d-none', active); + // this.activeImgEl.classList.toggle('d-none', !active); + // } + // this.el.classList.toggle('active', active); + } + + get active() { + return this._state.active; + } /** * @override */ - _makeDescriptive() { - const $el = this._super(...arguments); - if (this.illustrationEl) { - $el[0].classList.add('o_we_icon_button'); - } - if (this.activeImgEl) { - this.containerEl.appendChild(this.activeImgEl); - } - return $el; - }, - /** - * @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) { + getActiveValue(methodName) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; return possibleValues && possibleValues[possibleValues.length - 1] || ''; - }, + } /** * @override */ - isActive: function () { - return (this.isPreviewed() !== this.el.classList.contains('active')); - }, + isActive() { + return (this.isPreviewed() !== this._state.active); + } /** * @override */ - loadMethodsData: function (validMethodNames) { - this._super.apply(this, arguments); - for (const methodName of this._methodsNames) { + loadMethodsData(validMethodNames) { + super.loadMethodsData(...arguments); + for (const methodName of this._methodsNames.values()) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; if (possibleValues.length <= 1) { possibleValues.unshift(''); } } - }, + } +} + +export class WeButton extends UserValueComponent { + static template = "web_editor.WeButton"; + static StateModel = ButtonUserValue; + setup() { + super.setup(); + } /** * @override */ - async setValue(value, methodName) { + async willStart() { await this._super(...arguments); - let active = !!value; - if (methodName) { - if (!this._methodsNames.includes(methodName)) { - return; - } - active = (this.getActiveValue(methodName) === value); + if (this.options.dataAttributes.activeImg) { + // TODO: @owl-options: this is no longer used. + this.activeImgEl = await _buildImgElement(this.options.dataAttributes.activeImg); + } + } + /** + * @override + */ + _makeDescriptive() { + const $el = this._super(...arguments); + if (this.illustrationEl) { + $el[0].classList.add('o_we_icon_button'); } - if (this.illustrationEl && this.activeImgEl) { - this.illustrationEl.classList.toggle('d-none', active); - this.activeImgEl.classList.toggle('d-none', !active); + if (this.activeImgEl) { + this.containerEl.appendChild(this.activeImgEl); + } + return $el; + } + /** + * @override + */ + start(parent, title, options) { + if (this.options && this.options.childNodes) { + this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); } - this.el.classList.toggle('active', active); - }, + return this._super(...arguments); + } //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- @@ -872,33 +1164,28 @@ const ButtonUserValueWidget = UserValueWidget.extend({ /** * @private */ - _onButtonClick: function (ev) { + _onButtonClick(ev) { if (!ev._innerButtonClicked) { this._onUserValueChange(ev); } - }, + } /** * @private */ - _onInnerButtonClick: function (ev) { + _onInnerButtonClick(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', + } +} +registry.category("snippet_widgets").add("WeButton", WeButton); - /** - * @override - */ - start: function () { - const checkboxEl = document.createElement('we-checkbox'); - this.containerEl.appendChild(checkboxEl); +const ButtonUserValueWidget = Widget.extend({className: ''}); +const CheckboxUserValueWidget = ButtonUserValueWidget.extend({}); - return this._super(...arguments); - }, +class WeCheckbox extends WeButton { + static template = "web_editor.WeCheckbox"; + static components = { WeTitle }; //-------------------------------------------------------------------------- // Public @@ -908,8 +1195,9 @@ const CheckboxUserValueWidget = ButtonUserValueWidget.extend({ * @override */ enable() { + // TODO: @owl-options adapt this.$('we-checkbox').click(); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -923,30 +1211,32 @@ const CheckboxUserValueWidget = ButtonUserValueWidget.extend({ // Only consider clicks on the label and the checkbox control itself return; } - return this._super(...arguments); - }, -}); + return super._onButtonClick(...arguments); + } +} +registry.category("snippet_widgets").add("WeCheckbox", WeCheckbox); -const BaseSelectionUserValueWidget = UserValueWidget.extend({ +const BaseSelectionUserValueWidget = UserValueWidget.extend({}); +class BaseSelectionUserValue extends UserValue { /** * @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 => { - // Ensure to only put element nodes inside the selection menu - // as there could be an :empty CSS rule to handle the case when - // the menu is empty (so it should not contain any whitespace). - if (node.nodeType === Node.ELEMENT_NODE) { - this.menuEl.appendChild(node); - } - }); - } - this.containerEl.appendChild(this.menuEl); - }, + await super.start(...arguments); + + // this.menuEl = document.createElement('we-selection-items'); + // if (this.options && this.options.childNodes) { + // this.options.childNodes.forEach(node => { + // // Ensure to only put element nodes inside the selection menu + // // as there could be an :empty CSS rule to handle the case when + // // the menu is empty (so it should not contain any whitespace). + // if (node.nodeType === Node.ELEMENT_NODE) { + // this.menuEl.appendChild(node); + // } + // }); + // } + // this.containerEl.appendChild(this.menuEl); + } //-------------------------------------------------------------------------- // Public @@ -956,13 +1246,13 @@ const BaseSelectionUserValueWidget = UserValueWidget.extend({ * @override */ getMethodsParams(methodName) { - const params = this._super(...arguments); + const params = super.getMethodsParams(...arguments); const activeWidget = this._getActiveSubWidget(); if (!activeWidget) { return params; } return Object.assign(activeWidget.getMethodsParams(...arguments), params); - }, + } /** * @override */ @@ -971,23 +1261,23 @@ const BaseSelectionUserValueWidget = UserValueWidget.extend({ if (activeWidget) { return activeWidget.getActiveValue(methodName); } - return this._super(...arguments); - }, + return super.getValue(...arguments); + } /** * @override */ isContainer() { return true; - }, + } /** * @override */ async setValue(value, methodName) { - const _super = this._super.bind(this); - for (const widget of this._userValueWidgets) { + const subValues = Object.values(this._subValues); + for (const widget of subValues) { await widget.setValue(NULL_ID, methodName); } - for (const widget of [...this._userValueWidgets].reverse()) { + for (const widget of subValues.reverse()) { await widget.setValue(value, methodName); if (widget.isActive()) { // Only one select item can be true at a time, we consider the @@ -995,8 +1285,8 @@ const BaseSelectionUserValueWidget = UserValueWidget.extend({ break; } } - await _super(...arguments); - }, + await super.setValue(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -1004,23 +1294,129 @@ const BaseSelectionUserValueWidget = UserValueWidget.extend({ /** * @private - * @returns {UserValueWidget|undefined} + * @returns {UserValue|undefined} */ _getActiveSubWidget() { - const previewedWidget = this._userValueWidgets.find(widget => widget.isPreviewed()); + const previewedWidget = Object.values(this._subValues).find(value => value.isPreviewed()); if (previewedWidget) { return previewedWidget; } - return this._userValueWidgets.find(widget => widget.isActive()); - }, -}); + return Object.values(this._subValues).find(value => value.isActive()); + } +} +export class SelectUserValue extends BaseSelectionUserValue { -const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ - tagName: 'we-select', - events: { - 'click': '_onClick', - }, - PLACEHOLDER_TEXT: _t("None"), + static PLACEHOLDER_TEXT = _t("None"); + + constructor() { + super(...arguments); + this._state.toggler = { + faIcon: false, + imgSrc: false, + textContent: this.constructor.PLACEHOLDER_TEXT, + }; + } + /** + * Informations about the toggler element, to be used in the template. + * + * @type {Object} + */ + get toggler() { + return this._state.toggler; + } + /** + * @override + */ + async setValue(value, methodName) { + await super.setValue(...arguments); + // Re-initialising the toggler. + const newToggler = { + textContent: "", + faIcon: false, + imgSrc: false, + }; + const activeWidget = Object.values(this._subValues).find(value => !value.isPreviewed() && value.isActive()); + if (activeWidget) { + const params = activeWidget.getMethodsParams(methodName); + // Check the param for svg on buttons + const svgSrc = params.svgSrc; // useful to avoid searching text content in svg element + const text = (params.selectLabel || (!svgSrc && activeWidget.textContent.trim())); + const imgSrc = params.img; + const icon = params.icon; + if (text) { + newToggler.textContent = text; + } else if (icon) { + newToggler.faIcon = icon; + } else if (imgSrc) { + newToggler.imgSrc = imgSrc; + } else { + // TODO: @owl-options: check this? See the "border-style" option + // that appears when you add a border for an example. e.g.: on + // Images Wall. + if (activeWidget.fakeImg) { + newToggler.textContent = activeWidget.fakeImg; + } + } + } else { + newToggler.textContent = this.constructor.PLACEHOLDER_TEXT; + } + + this._state.toggler = newToggler; + } + /** + * @override + */ + isPreviewed() { + return super.isPreviewed() || this.opened; + } + /** + * @override + */ + enable() { + if (!this.opened) { + this.open(); + } + } +} +const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({}); +export class WeSelect extends UserValueComponent { + static isContainer = true; + static template = "web_editor.WeSelect"; + static components = { Dropdown, WeTitle }; + static StateModel = SelectUserValue; + static props = { + ...UserValueComponent.props, + title: { type: String, optional: true }, + } + + setup() { + // Use this ref to adjust the dropdown's position upwards if not enough + // space downwards. + this.menuRef = useRef("menu"); + + useEffect( + (opened) => { + if (opened) { + this._adjustDropdownPosition(); + } + return () => { + const containerEl = this.menuRef.el?.closest(".o_we_user_value_widget"); + containerEl.classList.remove("o_we_select_dropdown_up"); + }; + }, + () => [this.state.opened] + ); + + super.setup(); + } + /** + * @override + */ + getStateParams() { + const propsCopy = super.getStateParams(); + delete propsCopy.title; + return propsCopy; + } /** * @override @@ -1051,89 +1447,7 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ const dropdownCaretEl = document.createElement('span'); dropdownCaretEl.classList.add('o_we_dropdown_caret'); this.containerEl.appendChild(dropdownCaretEl); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - close: function () { - this._super(...arguments); - this.el.classList.remove("o_we_select_dropdown_up"); - 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'); - this._adjustDropdownPosition(); - }, - /** - * @override - */ - async setValue() { - await this._super(...arguments); - - if (this.iconEl) { - 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; - const icon = activeWidget.el.dataset.icon; - if (value) { - textContent = value; - } else if (icon) { - this.menuTogglerItemEl = document.createElement('i'); - this.menuTogglerItemEl.classList.add('fa', icon); - } 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.PLACEHOLDER_TEXT; - } - - this.menuTogglerEl.textContent = textContent; - if (this.menuTogglerItemEl) { - this.menuTogglerEl.appendChild(this.menuTogglerItemEl); - } - }, - /** - * @override - */ - enable() { - if (!this.menuTogglerEl.classList.contains('active')) { - this.menuTogglerEl.click(); - } - }, + } //-------------------------------------------------------------------------- // Private @@ -1145,7 +1459,7 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ */ _shouldIgnoreClick(ev) { return !!ev.target.closest('[role="button"]'); - }, + } /** * Decides whether the dropdown should be positioned below or above the * selector based on the available space. @@ -1153,14 +1467,22 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ * @private */ _adjustDropdownPosition() { - const customizePanelEl = this.menuEl.closest(".o_we_customize_panel"); + if (!this.state.opened) { + return; + } + const customizePanelEl = this.menuRef.el?.closest(".o_we_customize_panel"); if (!customizePanelEl) { return; } - this.el.classList.remove("o_we_select_dropdown_up"); + const containerEl = this.menuRef.el.closest(".o_we_user_value_widget"); + if (!containerEl) { + return; + } + + containerEl.classList.remove("o_we_select_dropdown_up"); const customizePanelElCoords = customizePanelEl.getBoundingClientRect(); - let dropdownMenuElCoords = this.menuEl.getBoundingClientRect(); + let dropdownMenuElCoords = this.menuRef.el.getBoundingClientRect(); // Adds a margin to prevent the dropdown from sticking to the edge of // the customize panel. @@ -1168,15 +1490,15 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ // If after opening, the dropdown list overflows the customization // panel at the bottom, opens the dropdown above the selector. if ((dropdownMenuElCoords.bottom + dropdownMenuMargin) > customizePanelElCoords.bottom) { - this.el.classList.add("o_we_select_dropdown_up"); - dropdownMenuElCoords = this.menuEl.getBoundingClientRect(); + containerEl.classList.add("o_we_select_dropdown_up"); + dropdownMenuElCoords = this.menuRef.el.getBoundingClientRect(); // If there is no available space above it either, then we open // it below the selector. if (dropdownMenuElCoords.top < customizePanelElCoords.top) { - this.el.classList.remove("o_we_select_dropdown_up"); + containerEl.classList.remove("o_we_select_dropdown_up"); } } - }, + } //-------------------------------------------------------------------------- // Handlers @@ -1187,40 +1509,39 @@ const SelectUserValueWidget = BaseSelectionUserValueWidget.extend({ * * @private */ - _onClick: function (ev) { + _onClick(ev) { if (this._shouldIgnoreClick(ev)) { return; } - if (!this.menuTogglerEl.classList.contains('active')) { - this.open(); + if (!this.state.opened) { + this.state.open(); } else { - this.close(); + this.state.close(); } - const activeButton = this._userValueWidgets.find(widget => widget.isActive()); - if (activeButton) { - this.menuEl.scrollTop = activeButton.el.offsetTop - (this.menuEl.offsetHeight / 2); + const activeItem = this.menuRef.el?.querySelector(".active"); + if (activeItem) { + this.menuRef.el.scrollTop = activeItem.offsetTop - (this.menuRef.el.offsetHeight / 2); } - }, -}); + } +} +registry.category("snippet_widgets").add("WeSelect", WeSelect); + +class WeButtonGroup extends UserValueComponent { + static template = "web_editor.WeButtonGroup"; + static isContainer = true; + static components = { WeTitle }; + static StateModel = BaseSelectionUserValue; +} + +registry.category("snippet_widgets").add("WeButtonGroup", WeButtonGroup); 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); - }, +const UnitUserValueWidget = UserValueWidget.extend({}); +class UnitUserValue extends UserValue { //-------------------------------------------------------------------------- // Public @@ -1229,13 +1550,17 @@ const UnitUserValueWidget = UserValueWidget.extend({ /** * @override */ - getActiveValue: function (methodName) { - const activeValue = this._super(...arguments); + getActiveValue(methodName) { + const activeValue = this._nextValue || super.getActiveValue(...arguments) || ""; const params = this._methodsParams; if (!this._isNumeric()) { return activeValue; } + // TODO find correct way to apply this + if (params.saveUnit === undefined) { + params.saveUnit = params.unit; + } const defaultValue = this.getDefaultValue(methodName, false); @@ -1248,13 +1573,13 @@ const UnitUserValueWidget = UserValueWidget.extend({ return `${this._floatToStr(value)}${params.saveUnit}`; } }).join(' '); - }, + } /** * @override * @param {boolean} [useInputUnit=false] */ - getDefaultValue: function (methodName, useInputUnit) { - const defaultValue = this._super(...arguments); + getDefaultValue(methodName, useInputUnit) { + const defaultValue = super.getDefaultValue(...arguments); const params = this._methodsParams; if (!this._isNumeric()) { @@ -1267,21 +1592,32 @@ const UnitUserValueWidget = UserValueWidget.extend({ return defaultValue; } return `${this._floatToStr(numValue)}${unit}`; - }, + } /** * @override */ - isActive: function () { - const isSuperActive = this._super(...arguments); + isActive() { + const isSuperActive = super.isActive(...arguments); if (!this._isNumeric()) { return isSuperActive; } return isSuperActive && ( - this._floatToStr(parseFloat(this._value)) !== '0' + this._floatToStr(parseFloat(this.value)) !== '0' // Or is a composite value. - || !!this._value.match(/\d+\s+\d+/) + || !!this.value.match(/\d+\s+\d+/) ); - }, + } + /** + * @override + */ + loadMethodsData() { + super.loadMethodsData(...arguments); + const params = this._methodsParams; + const unit = params.unit || ''; + if (params.saveUnit === undefined) { + params.saveUnit = unit; + } + } /** * @override */ @@ -1296,8 +1632,8 @@ const UnitUserValueWidget = UserValueWidget.extend({ return this._floatToStr(numValue); }).join(' '); } - return this._super(value, methodName); - }, + return super.setValue(value, methodName); + } //-------------------------------------------------------------------------- // Private @@ -1310,9 +1646,9 @@ const UnitUserValueWidget = UserValueWidget.extend({ * @param {number} value * @returns {string} */ - _floatToStr: function (value) { + _floatToStr(value) { return `${parseFloat(value.toFixed(5))}`; - }, + } /** * Checks whether the widget contains a numeric value. * @@ -1320,60 +1656,64 @@ const UnitUserValueWidget = UserValueWidget.extend({ * @returns {Boolean} true if the value is numeric, false otherwise. */ _isNumeric() { - const params = this._methodsParams || this.el.dataset; + const params = this._methodsParams; return !!params.unit; - }, -}); - -const InputUserValueWidget = UnitUserValueWidget.extend({ - tagName: 'we-input', - events: { - 'input input': '_onInputInput', - 'blur input': '_onInputBlur', - 'change input': '_onUserValueChange', - 'keydown input': '_onInputKeydown', - }, + } +} +const InputUserValueWidget = UnitUserValueWidget.extend({}); +class InputUserValue extends UnitUserValue { /** * @override */ - start: async function () { - await this._super(...arguments); + async setValue() { + await super.setValue(...arguments); + this._oldValue = this.value; + } + /** + * @override + */ + _isNumeric() { + const isNumeric = super._isNumeric(...arguments); + const params = this._methodsParams; + return isNumeric || !!params.fakeUnit || !!params.step; + } +} +export class WeInput extends UserValueComponent { + static template = 'web_editor.WeInput'; + static props = { ...UserValueComponent.props, + unit: { type: String, optional: true }, + step: { type: String, optional: true }, + saveUnit: { type: String, optional: true }, + // withUnit: { type: String, optional: true }, // ? boolean ? + fakeUnit: { type: String, optional: true }, // ? boolean ? + hideUnit: { type: String, optional: true }, // ? boolean ? + extraClass: { type: String, optional: true }, + placeholder: { type: String, optional: true }, + }; + static defaultProps = { + unit: "", + }; + static components = { WeTitle }; + static StateModel = InputUserValue; - 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') || ''); - const useNumberAlignment = this._isNumeric() || !!this.el.dataset.hideUnit; - this.inputEl.classList.toggle('text-start', !useNumberAlignment); - this.inputEl.classList.toggle('text-end', useNumberAlignment); - this.containerEl.appendChild(this.inputEl); - - const showUnit = (!!unit || !!this.el.dataset.fakeUnit) && !this.el.dataset.hideUnit; - if (showUnit) { - var unitEl = document.createElement('span'); - const unitText = this.el.dataset.fakeUnit || unit; - unitEl.textContent = unitText; - this.containerEl.appendChild(unitEl); - if (unitText.length > 3) { - this.el.classList.add('o_we_large'); - } - } - }, + setup() { + super.setup(); + this.inputRef = useRef("input"); + } //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- - /** - * @override - */ - async setValue() { - await this._super(...arguments); - this.inputEl.value = this._value; - this._oldValue = this._value; - }, + getAllClasses() { + return { + ...super.getAllClasses(), + "o_we_large": ( + !this.props.unit && !this.props.fakeUnit + ) || this.props.hideUnit, + }; + } //-------------------------------------------------------------------------- // Private @@ -1383,16 +1723,8 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ * @override */ _getFocusableElement() { - return this.inputEl; - }, - /** - * @override - */ - _isNumeric() { - const isNumeric = this._super(...arguments); - const params = this._methodsParams || this.el.dataset; - return isNumeric || !!params.fakeUnit || !!params.step; - }, + return this.inputRef.el; + } //-------------------------------------------------------------------------- // Handlers @@ -1402,17 +1734,17 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ * @private * @param {Event} ev */ - _onInputInput: function (ev) { + _onInputInput(ev) { // First record the input value as the new current value and bound it if // necessary (min / max params). - this._value = this.inputEl.value; + this.state.value = this.inputRef.el?.value; - const params = this._methodsParams; + const params = this.props; const hasMin = ('min' in params); const hasMax = ('max' in params); if (hasMin || hasMax) { // Bounding the value in [min, max] if specified. - const boundedValue = this._value.split(/\s+/g).map(v => { + const boundedValue = this.state.value.split(/\s+/g).map(v => { let numValue = parseFloat(v); if (isNaN(numValue)) { return hasMin ? params.min : v; @@ -1425,14 +1757,14 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ // If the bounded version is different from the value, forget about // the old value so that we properly update the UI in any case. - this._oldValue = undefined; + this.state._oldValue = undefined; // Note: we do not change the input's value because we want the user // to be able to enter anything without it being auto-fixed. For // example, just emptying the input to enter new numbers: you don't // want the min value to pop up unexpectedly. The next UI update // will take care of showing the user that the value was bound. - this._value = boundedValue; + this.state.value = boundedValue; } // When the value changes as a result of a arrow up/down, the change @@ -1444,12 +1776,12 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ this.changeEventWillBeTriggered = true; } this._onUserValuePreview(ev); - }, + } /** * @private * @param {Event} ev */ - _onInputBlur: function (ev) { + _onInputBlur(ev) { if (this.notifyValueChangeOnBlur && !this.changeEventWillBeTriggered) { // In case the input value has been modified with arrow up/down, the // change event is not triggered (except if there has been a natural @@ -1459,14 +1791,14 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ this.notifyValueChangeOnBlur = false; } this.changeEventWillBeTriggered = false; - }, + } /** * @private * @param {Event} ev */ - _onInputKeydown: function (ev) { - const params = this._methodsParams; - if (!this._isNumeric()) { + _onInputKeydown(ev) { + const params = this.props; + if (!this.state._isNumeric()) { return; } switch (ev.key) { @@ -1475,6 +1807,7 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ break; case "ArrowUp": case "ArrowDown": { + ev.preventDefault(); // Do not let it be handled as an hotkey. const input = ev.currentTarget; let parts = (input.value || input.placeholder).match(/-?\d+\.\d+|-?\d+/g); if (!parts) { @@ -1512,7 +1845,7 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ value += (increasing ? step : -step); value = hasMin ? Math.max(params.min, value) : value; value = hasMax ? Math.min(value, params.max) : value; - return this._floatToStr(value); + return this.state._floatToStr(value); }).join(" "); if (newValue === (input.value || input.placeholder)) { return; @@ -1532,29 +1865,29 @@ const InputUserValueWidget = UnitUserValueWidget.extend({ break; } } - }, + } /** * @override */ _onUserValueChange() { - if (this._oldValue !== this._value) { - this._super(...arguments); + if (this.state._oldValue !== this.state.value) { + super._onUserValueChange(...arguments); } } -}); +} +registry.category("snippet_widgets").add("WeInput", WeInput); -const MultiUserValueWidget = UserValueWidget.extend({ - tagName: 'we-multi', +class MultiUserValue extends UserValue { - /** - * @override - */ - start: function () { - if (this.options && this.options.childNodes) { - this.options.childNodes.forEach(node => this.containerEl.appendChild(node)); + get sortedValues() { + if (!this._sortedValues) { + this._sortedValues = Object.values(this._subValues); + this._sortedValues.sort((a, b) => { + return a.multiSequence - b.multiSequence; + }); } - return this._super(...arguments); - }, + return this._sortedValues; + } //-------------------------------------------------------------------------- // Public @@ -1563,148 +1896,94 @@ const MultiUserValueWidget = UserValueWidget.extend({ /** * @override */ - getValue: function (methodName) { - const value = this._userValueWidgets.map(widget => { - return widget.getValue(methodName); + getValue(methodName) { + const value = this.sortedValues.map(subValue => { + return subValue.getValue(methodName); }).join(' ').trim(); - return value || this._super(...arguments); - }, - /** - * @override - */ - isContainer: function () { - return true; - }, + return value || super.getValue(...arguments); + } /** * @override */ async setValue(value, methodName) { + // TODO: @owl-options avoid null + value ||= ""; 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); + for (let i = 0; i < this.sortedValues.length - 1; i++) { + await this.sortedValues[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', - - /** - * @override - */ - start: async function () { - const _super = this._super.bind(this); - const args = arguments; - - this.resetTabCount = 0; - - // 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'); - // todo: This div should be removed whenever possible (like when - // converting the uservaluewidget to owl). - this.colorPaletteEl = document.createElement('div'); - this.colorPaletteEl.classList.add('o_we_color_palette_wrapper'); - this.colorPaletteEl.style.display = 'contents'; - this.colorPaletteColorNames = []; - this.options.childNodes = [this.colorPaletteEl]; - this.options.valueEl = this.colorPreviewEl; - // TODO: find a better way to do this. - // The colorpicker widget is started before the ColorPalette component - // is attached to the DOM (which only happens once the user opens the - // picker). However, the colorNames are only set after the ColorPalette - // has been mounted. Initializing the colorNames through a direct call - // to the `getColorPickerTemplateService` so that the widget starts - // with possible default values is thus necessary to avoid bugs on - // `_computeWidgetState()`. - const wysiwyg = this.getParent().options.wysiwyg; - if (wysiwyg) { - const colorpickerTemplate = await wysiwyg.getColorpickerTemplate.call(wysiwyg); - this.colorPaletteColorNames = this._getColorNames(colorpickerTemplate); - } - return _super(...args); - }, + await this.sortedValues[this.sortedValues.length - 1].setValue(values.join(' '), methodName); + } +} - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- +const MultiUserValueWidget = UserValueWidget.extend({}); +class WeMulti extends UserValueComponent { + static template = "web_editor.WeMulti"; + static isContainer = true; + static StateModel = MultiUserValue; + static components = { WeTitle }; +} +registry.category("snippet_widgets").add("WeMulti", WeMulti); - /** - * @override +export class ColorpickerUserValue extends SelectUserValue { + /** + * The Color Combination value, which is a string ranging from 1 to 5 + * + * @type {string} */ - open: function () { - if (this.colorPaletteWrapper) { - this.colorPaletteWrapper?.update({ - selectedCC: this._ccValue, - selectedColor: this._value, - resetTabCount: ++this.resetTabCount, - }); - this._super(...arguments); - } 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); - this._colorPaletteRenderPromise.then(() => { - // Re-adjust the position of the colorpicker once the - // colorpalette is completely rendered (once that the - // colorpicker has its final height. - // TODO should not be needed once everything will be converted - // to owl. - this._adjustDropdownPosition(); - }); - } - }, + get ccValue() { + return this._state.ccValue; + } + set ccValue(value) { + this._state.ccValue = value; + } /** - * @override + * A custom color value, selected through the color picker of the color + * palette + * + * @type {string} */ - close: function () { - this._super(...arguments); - if (this._customColorValue && this._customColorValue !== this._value) { - this._value = this._customColorValue; - this._customColorValue = false; - this._onUserValueChange(); - } - }, + get customColorValue() { + return this._state.cutomColorValue; + } + set customColorValue(value) { + this._state.customColorValue = value; + } /** * @override */ - getMethodsParams: function () { - return Object.assign(this._super(...arguments), { + getMethodsParams() { + return Object.assign(super.getMethodsParams(...arguments), { colorNames: this.colorPaletteColorNames, }); - }, + } /** * @override */ - getValue: function (methodName) { + getValue(methodName) { const isCCMethod = (this._methodsParams.withCombinations === methodName); - let value = this._super(...arguments); + let value = super.getValue(...arguments); if (isCCMethod) { - value = this._ccValue; - } else if (typeof this._customColorValue === 'string') { - value = this._customColorValue; + value = this.ccValue; + } else if (typeof this.customColorValue === 'string') { + value = this.customColorValue; } // TODO strange there is some processing below for the normal value but // not for the preview value? To check in older stable versions as well. - if (typeof this._previewColor === 'string') { - return isCCMethod ? this._previewCC : this._previewColor; + if (typeof this.previewColor === 'string') { + return isCCMethod ? this.previewCC : this.previewColor; } if (value) { // TODO probably something to be done to handle gradients properly // in this code. - const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor'); - const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible'); + const useCssColor = this._data.useCssColor; + const cssCompatible = this._data.cssCompatible; if ((useCssColor || cssCompatible) && !isCSSColor(value)) { if (useCssColor) { value = weUtils.getCSSVariableValue(value); @@ -1714,20 +1993,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ } } return value; - }, - /** - * @override - */ - isContainer: function () { - return false; - }, - /** - * @override - */ - isActive: function () { - return !!this._ccValue - || !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); - }, + } /** * Updates the color preview + re-render the whole color palette widget. * @@ -1740,30 +2006,134 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ // available in `_ccValue`. const isCCMethod = (this._methodsParams.withCombinations === methodName); // Always call _super but don't change _value if meant for the CC value. - await this._super(isCCMethod ? this._value : color, methodName, ...rest); + await super.setValue(isCCMethod ? this.value : color, methodName, ...rest); if (isCCMethod) { - this._ccValue = color; + this.ccValue = color; } + } + /** + * @override + */ + isActive() { + return !!this.ccValue + || !weUtils.areCssValuesEqual(this.value, 'rgba(0, 0, 0, 0)'); + } + /** + * @override + */ + close() { + super.close(); + if (this._state.customColorValue && this._state.customColorValue !== this._state.value) { + this._state.value = this._state.customColorValue; + this._state.customColorValue = false; + this.notifyValueChange(false); + } + } +} - await this._colorPaletteRenderPromise; +export class WeColorpicker extends WeSelect { + static StateModel = ColorpickerUserValue; + static template = "web_editor.WeColorpicker"; + static components = { ...WeSelect.components, ColorPalette }; + static isContainer = false; + + setup() { + super.setup(); + this.getColorPickerTemplateService = useService("get_color_picker_template"); + onWillStart(async () => { + // TODO: find a better way to do this. + // The colorpicker widget is started before the ColorPalette component + // is attached to the DOM (which only happens once the user opens the + // picker). However, the colorNames are only set after the ColorPalette + // has been mounted. Initializing the colorNames through a direct call + // to the `getColorPickerTemplateService` so that the widget starts + // with possible default values is thus necessary to avoid bugs on + // `_computeWidgetState()`. + const colorpickerTemplate = await this.getColorPickerTemplateService(); + this.state.colorPaletteColorNames = this._getColorNames(colorpickerTemplate); + }); + this.colorPaletteColorNames = []; + // Colorpalette Props. + this.resetTabCount = 0; + const options = { + getCustomColors: () => { + let result = []; + if (this.env.getCustomColors) { + result = this.env.getCustomColors(); + } + return result; + }, + onCustomColorPicked: this._onCustomColorPicked.bind(this), + onColorPicked: this._onColorPicked.bind(this), + onColorHover: this._onColorHovered.bind(this), + onColorLeave: this._onColorLeft.bind(this), + onInputEnter: this._onEnterKey.bind(this), + }; + if (this.props.excluded) { + options.excluded = this.props.excluded.replace(/ /g, '').split(','); + } + if (this.props.opacity) { + options.opacity = parseFloat(this.props.opacity); + } + if (this.props.withCombinations) { + options.withCombinations = !!this.props.withCombinations; + } + if (this.props.withGradients) { + options.withGradients = !!this.props.withGradients; + } + if (this.props.noTransparency) { + options.noTransparency = !!this.props.noTransparency; + options.excluded = [...(options.excluded || []), 'transparent_grayscale']; + } + if (this.props.selectedTab) { + options.selectedTab = this.props.selectedTab; + } + options.getTemplate = this.getColorPickerTemplateService; + this.options = options; + } + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + /** + * @override + */ + isContainer() { + return false; + } + /** + * @override + */ + isActive() { + return !!this._ccValue + || !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); + } + getPreviewAttributes() { const classes = weUtils.computeColorClasses(this.colorPaletteColorNames); - this.colorPreviewEl.classList.remove(...classes); - this.colorPreviewEl.style.removeProperty('background-color'); - this.colorPreviewEl.style.removeProperty('background-image'); - const prefix = this.options.dataAttributes.colorPrefix || 'bg'; - if (this._ccValue) { - this.colorPreviewEl.style.backgroundColor = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')})`; - this.colorPreviewEl.style.backgroundImage = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')}-gradient)`; - } - if (this._value) { - this.colorPreviewEl.style.backgroundImage = 'none'; - if (isCSSColor(this._value)) { - this.colorPreviewEl.style.backgroundColor = this._value; - } else if (weUtils.isColorGradient(this._value)) { - this.colorPreviewEl.style.backgroundImage = this._value; - } else if (weUtils.EDITOR_COLOR_CSS_VARIABLES.includes(this._value)) { - this.colorPreviewEl.style.backgroundColor = `var(--we-cp-${this._value}`; + // this.colorPreviewEl.classList.remove(...classes); + // this.colorPreviewEl.style.removeProperty('background-color'); + // this.colorPreviewEl.style.removeProperty('background-image'); + const prefix = this.props.colorPrefix || 'bg'; + const style = { + "background-color": "", + "background-image": "", + }; + const attributes = { + class: "", + style: "", + }; + if (this.state.ccValue) { + style["background-color"] = `var(--we-cp-o-cc${this.state.ccValue}-${prefix.replace(/-/, '')})`; + style["background-image"] = `var(--we-cp-o-cc${this.state.ccValue}-${prefix.replace(/-/, '')}-gradient)`; + } + if (this.state.value) { + style["background-image"] = "none"; + if (isCSSColor(this.state.value)) { + style["background-color"] = this.state.value; + } else if (weUtils.isColorGradient(this.state.value)) { + style["background-image"] = this.state.value; + } else if (weUtils.EDITOR_COLOR_CSS_VARIABLES.includes(this.state.value)) { + style["background-color"] = `var(--we-cp-${this.state.value}`; } else { // Checking if the className actually exists seems overkill but // it is actually needed to prevent a crash. As an example, if a @@ -1778,90 +2148,26 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ // space inside). In that case, we simply do not show any color. // We could choose to handle this split-value case specifically // but it was decided that this is enough for the moment. - const className = `bg-${this._value}`; + const className = `bg-${this.state.value}`; if (classes.includes(className)) { - this.colorPreviewEl.classList.add(className); + attributes.class = className; } } } + attributes.style = Object.entries(style).map(([k, v]) => `${k}: ${v}`).join("; "); // If the palette was already opened (e.g. modifying a gradient), the new DOM state must be // reflected in the palette, but the tab selection must not be impacted. - this.colorPaletteWrapper?.update({ - selectedCC: this._ccValue, - selectedColor: this._value, - }); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @returns {Promise} - */ - _renderColorPalette: async function () { - this.resetTabCount = 0; - const options = { - resetTabCount: this.resetTabCount, - selectedCC: this._ccValue, - selectedColor: this._value, - getCustomColors: () => { - let result = []; - this.trigger_up('get_custom_colors', { - onSuccess: (colors) => result = colors, - }); - return result; - }, - onCustomColorPicked: this._onCustomColorPicked.bind(this), - onColorPicked: this._onColorPicked.bind(this), - onColorHover: this._onColorHovered.bind(this), - onColorLeave: this._onColorLeft.bind(this), - onInputEnter: this._onEnterKey.bind(this), - }; - if (this.options.dataAttributes.excluded) { - options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(','); - } - if (this.options.dataAttributes.opacity) { - options.opacity = parseFloat(this.options.dataAttributes.opacity); - } - if (this.options.dataAttributes.withCombinations) { - options.withCombinations = !!this.options.dataAttributes.withCombinations; - } - if (this.options.dataAttributes.withGradients) { - options.withGradients = !!this.options.dataAttributes.withGradients; - } - if (this.options.dataAttributes.noTransparency) { - options.noTransparency = !!this.options.dataAttributes.noTransparency; - options.excluded = [...(options.excluded || []), 'transparent_grayscale']; - } - if (this.options.dataAttributes.selectedTab) { - options.selectedTab = this.options.dataAttributes.selectedTab; - } - const wysiwyg = this.getParent().options.wysiwyg; - if (wysiwyg) { - options.document = this.$target[0].ownerDocument; - options.getTemplate = wysiwyg.getColorpickerTemplate.bind(wysiwyg); - } - this.colorPaletteWrapper?.destroy(); - const sidebarDocument = this.colorPaletteEl.ownerDocument; - if (!(this.colorPaletteEl instanceof sidebarDocument.defaultView.HTMLElement)) { - // When inside an iframe, the element for mounting a component must - // be an instance of the iframe's HTMLElement, or else target - // validation for attachComponent fails. - const newEl = sidebarDocument.importNode(this.colorPaletteEl, true); - this.colorPaletteEl.before(newEl); - this.colorPaletteEl.remove(); - this.colorPaletteEl = newEl; - } - this.colorPaletteWrapper = await attachComponent(this, this.colorPaletteEl, ColorPalette, options); - }, + // TODO: @owl-options this used to be part of setValue, now it isn't. + // this.options.selectedCC = this.state.ccValue; + // this.options.selectedColor = this.state.value; + return attributes; + } /** * @override */ _shouldIgnoreClick(ev) { - return ev.originalEvent.__isColorpickerClick || this._super(...arguments); - }, + return ev.__isColorpickerClick || super._shouldIgnoreClick(...arguments); + } /** * Browses the colorpicker XML template to return all possible values of * [data-color]. @@ -1881,7 +2187,7 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ } }); return colorNames; - }, + } //-------------------------------------------------------------------------- // Handlers @@ -1894,75 +2200,67 @@ const ColorpickerUserValueWidget = SelectUserValueWidget.extend({ * @private * @param {Object} params */ - _onCustomColorPicked: function (params) { - this._customColorValue = params.color; - }, + _onCustomColorPicked(params) { + this.state.customColorValue = params.color; + } /** * Called when a color button is clicked -> confirms the preview. * * @private * @param {Object} params */ - _onColorPicked: function (params) { - this._previewCC = false; - this._previewColor = false; - this._customColorValue = false; + _onColorPicked(params) { + this.state.previewCC = false; + this.state.previewColor = false; + this.state.customColorValue = false; - this._ccValue = params.ccValue; - this._value = params.color; + this.state.ccValue = params.ccValue; + this.state.value = params.color; this._onUserValueChange(); - }, + } /** * Called when a color button is entered -> previews the background color. * * @private * @param {Object} params */ - _onColorHovered: function (params) { - this._previewCC = params.ccValue; - this._previewColor = params.color; + _onColorHovered(params) { + this.state.previewCC = params.ccValue; + this.state.previewColor = params.color; this._onUserValuePreview(); - }, + } /** * Called when a color button is left -> cancels the preview. * * @private */ - _onColorLeft: function () { - this._previewCC = false; - this._previewColor = false; + _onColorLeft() { + this.state.previewCC = false; + this.state.previewColor = false; this._onUserValueReset(); - }, + } /** * @private */ - _onEnterKey: function () { - this.close(); - }, -}); - -const MediapickerUserValueWidget = UserValueWidget.extend({ - tagName: 'we-button', - events: { - 'click': '_onEditMedia', - }, - + _onEnterKey() { + this.state.close(); + } /** * @override */ - async start() { - await this._super(...arguments); - if (this.options.dataAttributes.buttonStyle) { - const iconEl = document.createElement('i'); - iconEl.classList.add('fa', 'fa-fw', 'fa-camera'); - $(this.containerEl).prepend(iconEl); - } else { - this.el.classList.add('o_we_no_toggle', 'o_we_bg_success'); - this.containerEl.textContent = _t("Replace"); + _onClick() { + super._onClick(...arguments); + if (this.state.opened) { + this.options.resetTabCount++; } - }, + } +} +registry.category("snippet_widgets").add("WeColorpicker", WeColorpicker); + +const MediapickerUserValueWidget = UserValueWidget.extend({}); +class MediapickerUserValue extends UserValue { //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -1978,9 +2276,10 @@ const MediapickerUserValueWidget = UserValueWidget.extend({ * default: false */ _openDialog(el, {images = false, videos = false, save}) { - el.src = this._value; - const $editable = this.$target.closest('.o_editable'); - this.call("dialog", "add", MediaDialog, { + el.src = this.value || ""; + // @TODO: @owl-options: review this line. Put it in the env maybe? + const editableEl = this.option.$target[0].closest('.o_editable'); + this.env.services.dialog.add(MediaDialog, { noImages: !images, noVideos: !videos, noIcons: true, @@ -1988,12 +2287,28 @@ const MediapickerUserValueWidget = UserValueWidget.extend({ isForBgVideo: true, vimeoPreviewIds: ['528686125', '430330731', '509869821', '397142251', '763851966', '486931161', '499761556', '392935303', '728584384', '865314310', '511727912', '466830211'], - 'res_model': $editable.data('oe-model'), - 'res_id': $editable.data('oe-id'), + 'res_model': editableEl ? editableEl.dataset.oeModel : null, + 'res_id': editableEl ? editableEl.dataset.oeId : null, save, media: el, }); - }, + } + /** + * Opens a dialog to select a new image and sets the new src + */ + _editMedia() { + // Need a dummy element for the media dialog to modify. + const tagName = this._data.mediaType === "images" ? "img" : "iframe"; + const dummyEl = document.createElement(tagName); + this._openDialog(dummyEl, { + [this._data.mediaType]: true, + save: (media) => { + this.value = this._data.mediaType === "images" + ? media.getAttribute('src') + : media.querySelector(tagName).src; + this.notifyValueChange(false); + }}); + } //-------------------------------------------------------------------------- // Public @@ -2003,103 +2318,104 @@ const MediapickerUserValueWidget = UserValueWidget.extend({ * @override */ async setValue() { - await this._super(...arguments); - this.el.classList.toggle('active', this.isActive()); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - + await super.setValue(...arguments); + this._state.active = this.isActive(); + } /** - * Called when the edit button is clicked. - * - * @private - * @param {Event} ev + * @override */ - _onEditMedia: function (ev) {}, -}); + enable() { + this._editMedia(); + } +} + +class WeMediapicker extends UserValueComponent { + static template = "web_editor.WeMediapicker"; + static StateModel = MediapickerUserValue; + static props = { + ...UserValueComponent.props, + mediaType: { type: String }, // One of "images", "videos" + buttonStyle: { type: Boolean, optional: true }, + }; + static components = { WeTitle }; -const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({ //-------------------------------------------------------------------------- - // Handlers + // Public //-------------------------------------------------------------------------- /** * @override */ - _onEditMedia(ev) { - // Need a dummy element for the media dialog to modify. - const dummyEl = document.createElement('img'); - this._openDialog(dummyEl, { - images: true, - save: (media) => { - // 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 = media.getAttribute('src'); - this._onUserValueChange(); - } - }); - }, -}); + getAllClasses() { + return { + ...super.getAllClasses(), + "o_we_no_toggle o_we_bg_success": !this.props.buttonStyle, + }; + } -const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({ //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** - * @override - */ + * Called when the edit button is clicked. + * + * @private + * @param {Event} ev + */ _onEditMedia(ev) { - // Need a dummy element for the media dialog to modify. - const dummyEl = document.createElement('iframe'); - this._openDialog(dummyEl, { - videos: true, - save: (media) => { - this._value = media.querySelector('iframe').src; - this._onUserValueChange(); - }}); - }, -}); + this.state._editMedia(); + } +} +registry.category("snippet_widgets").add("WeMediapicker", WeMediapicker); -const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ - events: { // Explicitely not consider all InputUserValueWidget events - 'blur input': '_onInputBlur', - 'input input': '_onDateInputInput', - }, - pickerType: 'datetime', +const ImagepickerUserValueWidget = MediapickerUserValueWidget.extend({}); +const VideopickerUserValueWidget = MediapickerUserValueWidget.extend({}); +const DatetimePickerUserValueWidget = InputUserValueWidget.extend({}); +class WeDatetime extends WeInput { + static template = "web_editor.WeDatetime"; + static props = { ...WeInput.props, + pickerType: { type: String, optional: true }, + }; + static defaultProps = { + pickerType: "datetime", + }; /** * @override */ - init: function () { - this._super(...arguments); - this._value = DateTime.now().toUnixInteger().toString(); - }, - /** - * @override - */ - start: async function () { - await this._super(...arguments); + setup() { + super.setup(); + this.datetimePicker = useService("datetime_picker"); + onMounted(() => { + this.picker = this.datetimePicker.create({ + target: this.inputRef.el, + onChange: this._onDateTimePickerChange.bind(this), + pickerProps: { + type: this.props.pickerType, + minDate: DateTime.fromObject({ year: 1000 }), + maxDate: DateTime.now().plus({ year: 200 }), + value: this.state.value, + rounding: 0, + }, + }); + this.picker.enable(); + }); - this.el.classList.add('o_we_large'); - this.inputEl.classList.add('datetimepicker-input', 'mx-0', 'text-start'); - - this.picker = this.call("datetime_picker", "create", { - target: this.inputEl, - onChange: this._onDateTimePickerChange.bind(this), - pickerProps: { - type: this.pickerType, - minDate: DateTime.fromObject({ year: 1000 }), - maxDate: DateTime.now().plus({ year: 200 }), - value: DateTime.fromSeconds(parseInt(this._value)), - rounding: 0, + useEffect( + (value) => { + let dateTime = null; + if (value) { + dateTime = DateTime.fromSeconds(parseInt(value)); + if (!dateTime.isValid) { + dateTime = DateTime.now(); + } + } + this.picker.state.value = dateTime; }, - }); - this.picker.enable(); - }, + () => [this.state.value] + ); + } //-------------------------------------------------------------------------- // Public @@ -2108,31 +2424,17 @@ const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ /** * @override */ - getMethodsParams: function () { + getMethodsParams() { return Object.assign(this._super(...arguments), { format: this.defaultFormat, }); - }, + } /** * @override */ - isPreviewed: function () { + isPreviewed() { return this._super(...arguments) || this.picker.isOpen; - }, - /** - * @override - */ - async setValue() { - await this._super(...arguments); - let dateTime = null; - if (this._value) { - dateTime = DateTime.fromSeconds(parseInt(this._value)) - if (!dateTime.isValid) { - dateTime = DateTime.now(); - } - } - this.picker.state.value = dateTime; - }, + } //-------------------------------------------------------------------------- // Handlers @@ -2142,14 +2444,14 @@ const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ * @private * @param {Event} ev */ - _onDateTimePickerChange: function (newDateTime) { + _onDateTimePickerChange(newDateTime) { if (!newDateTime || !newDateTime.isValid) { - this._value = ''; + this.state.value = ""; } else { - this._value = newDateTime.toUnixInteger().toString(); + this.state.value = newDateTime.toUnixInteger().toString(); } this._onUserValuePreview(); - }, + } /** * Handles the clear button of the datepicker. * @@ -2157,74 +2459,51 @@ const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ * @param {Event} ev */ _onDateInputInput(ev) { - if (!this.inputEl.value) { - this._value = ''; + if (!this.inputRef.el.value) { + this.state.value = ""; this._onUserValuePreview(ev); } - }, -}); + } +} +registry.category("snippet_widgets").add("WeDatetime", WeDatetime); -const DatePickerUserValueWidget = DatetimePickerUserValueWidget.extend({ - pickerType: 'date', -}); +const DatePickerUserValueWidget = DatetimePickerUserValueWidget.extend({}); -const ListUserValueWidget = UserValueWidget.extend({ - tagName: 'we-list', - events: { - 'click we-button.o_we_select_remove_option': '_onRemoveItemClick', - 'click we-button.o_we_list_add_optional': '_onAddCustomItemClick', - 'click we-button.o_we_list_add_existing': '_onAddExistingItemClick', - 'click we-select.o_we_user_value_widget.o_we_add_list_item': '_onAddItemSelectClick', - 'click we-button.o_we_checkbox_wrapper': '_onAddItemCheckboxClick', - 'input table input': '_onListItemBlurInput', - 'blur table input': '_onListItemBlurInput', - 'mousedown': '_onWeListMousedown', - }, +const ListUserValueWidget = UserValueWidget.extend({}); +class ListUserValue extends UserValue { + constructor() { + super(...arguments); + this._state.listRecords = []; + this._state.selected = []; - /** - * @override - */ - willStart() { - if (this.options.createWidget) { - this.createWidget = this.options.createWidget; - this.createWidget.setParent(this); - this.registerSubWidget(this.createWidget); + if (this._data.availableRecords) { + this.existingRecords = JSON.parse(this._data.availableRecords); + } else { + this.isCustom = this._data.notEditable !== "true"; } - return this._super(...arguments); - }, + if (this._data.defaults || this._data.hasDefault) { + this.hasDefault = this._data.hasDefault || 'unique'; + this._state.selected = this._data.defaults ? JSON.parse(this._data.defaults) : []; + } + } /** - * @override + * @type {Object[]} */ - start() { - this.addItemTitle = this.el.dataset.addItemTitle || _t("Add"); - if (this.el.dataset.availableRecords) { - this.records = JSON.parse(this.el.dataset.availableRecords); - } else { - this.isCustom = !this.el.dataset.notEditable; - } - if (this.el.dataset.defaults || this.el.dataset.hasDefault) { - this.hasDefault = this.el.dataset.hasDefault || 'unique'; - this.selected = this.el.dataset.defaults ? JSON.parse(this.el.dataset.defaults) : []; - } - this.listTable = document.createElement('table'); - const tableWrapper = document.createElement('div'); - tableWrapper.classList.add('o_we_table_wrapper'); - tableWrapper.appendChild(this.listTable); - this.containerEl.appendChild(tableWrapper); - this.el.classList.add('o_we_fw'); - this._makeListItemsSortable(); - if (this.createWidget) { - return this.createWidget.appendTo(this.containerEl); - } - }, - + get listRecords() { + return this._state.listRecords; + } + set listRecords(value) { + this._state.listRecords = value; + } /** - * @override + * @type {integer|string[]} */ - destroy() { - this.bindedSortable?.cleanup(); - this._super(...arguments); - }, + get selected() { + return this._state.selected; + } + set selected(value) { + this._state.selected = value; + } //-------------------------------------------------------------------------- // Public @@ -2234,75 +2513,84 @@ const ListUserValueWidget = UserValueWidget.extend({ * @override */ getMethodsParams() { - return Object.assign(this._super(...arguments), { - records: this.records, + return Object.assign(super.getMethodsParams(...arguments), { + records: this.existingRecords, }); - }, + } /** * @override */ setValue() { - this._super(...arguments); - const currentValues = this._value ? JSON.parse(this._value) : []; - this.listTable.innerHTML = ''; - if (this.addItemButton) { - this.addItemButton.remove(); - } + super.setValue(...arguments); + const currentValues = this._state.value ? JSON.parse(this._state.value) : []; - if (this.createWidget) { + if (this.createUserValue) { const selectedIds = currentValues.map(({ id }) => id) .filter(id => typeof id === 'number'); // Note: it's important to simplify the domain at its maximum as the // rpc using it are cached. Similar domain components should be // written the same way for the cache to work. - this.createWidget.options.domainComponents.selected = selectedIds.length ? ['id', 'not in', selectedIds] : null; - this.createWidget.setValue(''); - this.createWidget.inputEl.value = ''; - $(this.createWidget.inputEl).trigger('input'); - } else { - if (this.isCustom) { - this.addItemButton = document.createElement('we-button'); - this.addItemButton.textContent = this.addItemTitle; - this.addItemButton.classList.add('o_we_list_add_optional'); - } else { - // TODO use a real select widget ? - this.addItemButton = document.createElement('we-select'); - this.addItemButton.classList.add('o_we_user_value_widget', 'o_we_add_list_item'); - const divEl = document.createElement('div'); - this.addItemButton.appendChild(divEl); - const togglerEl = document.createElement('we-toggler'); - togglerEl.textContent = this.addItemTitle; - divEl.appendChild(togglerEl); - this.selectMenuEl = document.createElement('we-selection-items'); - divEl.appendChild(this.selectMenuEl); - } - this.containerEl.appendChild(this.addItemButton); - } - currentValues.forEach(value => { - if (typeof value === 'object') { - const recordData = value; - const { id, display_name } = recordData; - delete recordData.id; - delete recordData.display_name; - this._addItemToTable(id, display_name, recordData); + this.createUserValue.options.domainComponents.selected = selectedIds.length ? ['id', 'not in', selectedIds] : null; + this.createUserValue.setValue(""); + // Reset the search with an empty needle and the proper selected ids + this.createUserValue._search(""); + // Reset the input value after creating a new record. The actual + // input element's value is reset `_onClick` of the WeMany2one. + this.createUserValue.createInputValue = ""; + } + this.listRecords = []; + this.selected = []; + currentValues.forEach((value) => { + if (typeof value === "object") { + const record = this._updateListRecords(value); + if (value.selected) { + this.selected.push(record.id); + } } else { - this._addItemToTable(value, value); + this.listRecords.push({ id: value, display_name: value }); + this.selected.push(value); } }); - if (!this.createWidget && !this.isCustom) { - this._reloadSelectDropdown(currentValues); - } - this._makeListItemsSortable(); - }, + } /** * @override */ getValue(methodName) { - if (this.createWidget && this.createWidget.getMethodsNames().includes(methodName)) { - return this.createWidget.getValue(methodName); + if (this.createUserValue?.getMethodsNames().includes(methodName)) { + return this.createUserValue.getValue(methodName); } - return this._value; - }, + return this._state.value; + } + /** + * @override + */ + registerUserValue(userValue) { + if (userValue instanceof Many2oneUserValue && userValue._data.createUserValue) { + this.createUserValue = userValue; + } + super.registerUserValue(userValue); + } + /** + * @override + */ + userValueNotification(params) { + const { widget, previewMode, prepare } = params; + if (widget && widget === this.createUserValue) { + if (widget.options.createMethod && widget.getValue(widget.options.createMethod)) { + return super.userValueNotification(...arguments); + } + if (previewMode) { + return; + } + prepare(); + const recordData = JSON.parse(widget.getMethodsParams('addRecord').recordData); + if (!this.listRecords.find((record) => record.id === recordData.id)) { + this._updateListRecords(recordData); + this._notifyCurrentState(); + } + } + return super.userValueNotification(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -2310,220 +2598,250 @@ const ListUserValueWidget = UserValueWidget.extend({ /** * @private - * @param {string || integer} id - * @param {string} [value] - * @param {Object} [recordData] key, values that will be added to the - * element's dataset + * @param {Object} newRecord */ - _addItemToTable(id, value = this.el.dataset.defaultValue || _t("Item"), recordData) { - const trEl = document.createElement('tr'); - if (!this.el.dataset.unsortable) { - const draggableEl = document.createElement('we-button'); - draggableEl.classList.add('o_we_drag_handle', 'o_we_link', 'fa', 'fa-fw', 'fa-arrows'); - draggableEl.dataset.noPreview = 'true'; - const draggableTdEl = document.createElement('td'); - draggableTdEl.appendChild(draggableEl); - trEl.appendChild(draggableTdEl); + _updateListRecords(newRecord) { + let id = newRecord.id; + // Create an ID if there is none, and avoid duplicate IDs. + if ( + !newRecord.id + || this.listRecords.find((record) => record.id === this._toComparableId(newRecord.id)) + ) { + id = uniqueId("list_record_"); } - let recordDataSelected = false; - const inputEl = document.createElement('input'); - inputEl.type = this.el.dataset.inputType || 'text'; - if (value) { - inputEl.value = value; - } - if (id) { - inputEl.name = id; - } - if (recordData) { - recordDataSelected = recordData.selected; - if (recordData.placeholder) { - inputEl.placeholder = recordData.placeholder; - } - for (const key of Object.keys(recordData)) { - inputEl.dataset[key] = recordData[key]; - } - } - inputEl.disabled = !this.isCustom; - const inputTdEl = document.createElement('td'); - inputTdEl.classList.add('o_we_list_record_name'); - inputTdEl.appendChild(inputEl); - trEl.appendChild(inputTdEl); - if (this.hasDefault) { - const checkboxEl = document.createElement('we-button'); - checkboxEl.classList.add('o_we_user_value_widget', 'o_we_checkbox_wrapper'); - if (this.selected.includes(id) || recordDataSelected) { - checkboxEl.classList.add('active'); - } - if (!recordData || !recordData.notToggleable) { - const div = document.createElement('div'); - const checkbox = document.createElement('we-checkbox'); - div.appendChild(checkbox); - checkboxEl.appendChild(div); - checkboxEl.appendChild(checkbox); - const checkboxTdEl = document.createElement('td'); - checkboxTdEl.appendChild(checkboxEl); - trEl.appendChild(checkboxTdEl); - } - } - if (!recordData || !recordData.undeletable) { - const buttonTdEl = document.createElement('td'); - const buttonEl = document.createElement('we-button'); - buttonEl.classList.add('o_we_select_remove_option', 'o_we_link', 'o_we_text_danger', 'fa', 'fa-fw', 'fa-minus'); - buttonEl.dataset.removeOption = id; - buttonTdEl.appendChild(buttonEl); - trEl.appendChild(buttonTdEl); - } - this.listTable.appendChild(trEl); - }, + const record = Object.assign({}, newRecord, { + id: this._toComparableId(id), + name: this._trimmed(newRecord.name), + display_name: this._trimmed(newRecord.display_name) || this._data.defaultValue, + }); + this.listRecords.push(record); + return record; + } + /** + * @param {string} str + * @returns {string} + */ + _trimmed(str) { + if (typeof str !== "string") { + return str; + } + return str.trim().replace(/\s+/g, " "); + } + /** + * Cast ids to the right type (number or string) to compare them between + * record / state / DOM. + * + * @private + * @param {string|number} id + * @returns {string|number} + */ + _toComparableId(id) { + if (typeof id === "string") { + id = this._trimmed(id); + return /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id; + } + return id; + } +} + +class WeList extends UserValueComponent { + static template = "web_editor.WeList"; + static StateModel = ListUserValue; + static props = { + ...UserValueComponent.props, + availableRecords: { type: String, optional: true }, + hasDefault: { type: String, optional: true }, + inputType: { type: String, optional: true }, + defaults: { type: String, optional: true }, + defaultValue: { type: String, optional: true }, + addItemTitle: { type: String, optional: true }, + unsortable: { type: String, optional: true }, + notEditable: { type: String, optional: true }, + allowEmpty: { type: String, optional: true }, + newElementsNotToggleable: { type: String, optional: true }, + renderOnInputBlur: { type: String, optional: true }, + doubleInput: { type: String, optional: true }, + }; + static defaultProps = { + inputType: "text", + defaultValue: _t("Item"), + addItemTitle: _t("Add"), + }; + static isContainer = true; + static components = { WeTitle }; + + setup() { + super.setup(); + this.tableRef = useRef("table"); + + this._makeListItemsSortable(); + this.state._notifyCurrentState = this._notifyCurrentState.bind(this); + + useEffect( + () => { + if (this.scrollToLast) { + // Scroll to the new list element. + this.tableRef.el.querySelector('tr:last-child') + .scrollIntoView({behavior: 'smooth', block: 'nearest'}); + this.scrollToLast = false; + } + }, + () => [this.state.listRecords.length] + ); + } + + //------------------------------------------------------------------ + // PRIVATE + //------------------------------------------------------------------ + /** * @override */ _getFocusableElement() { - return this.listTable.querySelector('input'); - }, + return this.tableRef.el.querySelector("input"); + } /** * @private */ _makeListItemsSortable() { - if (this.el.dataset.unsortable) { + if (this.props.unsortable === "true") { return; } - this.bindedSortable = this.call( - "sortable", - "create", - { - ref: { el: this.listTable }, - elements: "tr", - followingElementClasses: ["opacity-50"], - handle: ".o_we_drag_handle", - onDrop: () => this._notifyCurrentState(), - applyChangeOnDrop: true, + useSortable({ + ref: this.tableRef, + elements: "tr", + followingElementClasses: ["opacity-50"], + handle: ".o_we_drag_handle", + onDrop: ({ element, next }) => { + const id = this.state._toComparableId(element.querySelector("input").name); + const recordIdx = this.state.listRecords.findIndex((rec) => rec.id === id); + const record = this.state.listRecords.splice(recordIdx, 1)[0]; + const insertIdx = next + ? this.state.listRecords.findIndex((rec) => + rec.id === this.state._toComparableId(next.querySelector("input").name) + ) : this.state.listRecords.length; + this.state.listRecords.splice(insertIdx, 0, record); + this._notifyCurrentState(); }, - ).enable(); - }, + applyChangeOnDrop: true, + }); + } /** * @private * @param {Boolean} [preview] */ _notifyCurrentState(preview = false) { - const isIdModeName = this.el.dataset.idMode === "name" || !this.isCustom; - const trimmed = (str) => str.trim().replace(/\s+/g, " "); - const values = [...this.listTable.querySelectorAll('.o_we_list_record_name input')].map(el => { - const id = trimmed(isIdModeName ? el.name : el.value); - return Object.assign({ - id: /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id, - name: trimmed(el.value), - display_name: trimmed(el.value), - }, el.dataset); - }); - if (this.hasDefault) { - const checkboxes = [...this.listTable.querySelectorAll('we-button.o_we_checkbox_wrapper.active')]; - this.selected = checkboxes.map(el => { - const input = el.parentElement.previousSibling.firstChild; - const id = trimmed(isIdModeName ? input.name : input.value); - return /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id; - }); - values.forEach(v => { + if (this.state.hasDefault) { + this.state.listRecords.forEach((v) => { + const id = this.state._toComparableId(v.id); // Elements not toggleable are considered as always selected. - // We have to check that it is equal to the string 'true' - // because this information comes from the dataset. - v.selected = this.selected.includes(v.id) || v.notToggleable === 'true'; + v.selected = this.state.selected.includes(id) || v.notToggleable; }); } - this._value = JSON.stringify(values); + + this.state.value = JSON.stringify(this.state.listRecords); if (preview) { this._onUserValuePreview(); } else { this._onUserValueChange(); } - if (!this.createWidget && !this.isCustom) { - this._reloadSelectDropdown(values); - } - }, + } /** + * Maps listRecord properties to data-attributes. + * * @private - * @param {Array} currentValues + * @param {Object} listRecord + * @returns {Object} */ - _reloadSelectDropdown(currentValues) { - this.selectMenuEl.innerHTML = ''; - this.records.forEach(el => { - if (!currentValues.find(v => v.id === el.id)) { - const option = document.createElement('we-button'); - option.classList.add('o_we_list_add_existing'); - option.dataset.addOption = el.id; - option.dataset.noPreview = 'true'; - const divEl = document.createElement('div'); - divEl.textContent = el.display_name; - option.appendChild(divEl); - this.selectMenuEl.appendChild(option); + getInputDataAtts(listRecord) { + return Object.entries(listRecord).reduce((obj, entry) => { + if (["display_name", "id", "placeholder"].includes(entry[0])) { + return obj; } - }); - if (!this.selectMenuEl.children.length) { - const title = document.createElement('we-title'); - title.textContent = _t("No more records"); - this.selectMenuEl.appendChild(title); - } - }, + obj[`data-${camelToKebab(entry[0])}`] = entry[1]; + return obj; + }, {}); + } //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- + /** + * @private + */ + _onClick(ev) { + if (!this.state.opened) { + this.state.open(); + } else { + this.state.close(); + } + } /** * @private */ _onAddCustomItemClick() { const recordData = {}; - if (this.el.dataset.newElementsNotToggleable) { + if (this.props.newElementsNotToggleable === "true") { recordData.notToggleable = true; } - this._addItemToTable(undefined, this.el.dataset.defaultValue, recordData); + if (this.props.newRecordId) { + recordData.id = this.props.newRecordId; + } + this.state._updateListRecords(recordData); this._notifyCurrentState(); - // Scroll to the new list element. - this.el.querySelector('tr:last-child') - .scrollIntoView({behavior: 'smooth', block: 'nearest'}); - }, + this.scrollToLast = true; + } /** * @private * @param {Event} ev */ _onAddExistingItemClick(ev) { - const value = ev.currentTarget.dataset.addOption; - this._addItemToTable(value, ev.currentTarget.textContent); + const record = this.state.existingRecords.find((record) => { + return record.id === this.state._toComparableId(ev.currentTarget.dataset.addOption); + }); + this.state._updateListRecords(record); this._notifyCurrentState(); - }, - /** - * @private - * @param {Event} ev - */ - _onAddItemSelectClick(ev) { - ev.currentTarget.querySelector('we-toggler').classList.toggle('active'); - }, + this.scrollToLast = true; + } /** * @private * @param {Event} ev */ - _onAddItemCheckboxClick: function (ev) { - const isActive = ev.currentTarget.classList.contains('active'); - if (this.hasDefault === 'unique') { - this.listTable.querySelectorAll('we-button.o_we_checkbox_wrapper.active').forEach(el => el.classList.remove('active')); + _onAddItemCheckboxClick(ev) { + const recordId = this.state._toComparableId(ev.currentTarget.dataset.select); + const isActive = this.state.selected.includes(recordId); + if (this.props.hasDefault === 'unique') { + this.state.selected = []; + } + if (isActive) { + this.state.selected.splice(this.state.selected.indexOf(recordId), 1); + } else { + this.state.selected.push(recordId); } - ev.currentTarget.classList.toggle('active', !isActive); this._notifyCurrentState(); - }, + } /** * @private * @param {Event} ev */ _onListItemBlurInput(ev) { const preview = ev.type === 'input'; - if (preview || !this.el.contains(ev.relatedTarget) || this.el.dataset.renderOnInputBlur) { + if (preview || !this.tableRef.el.contains(ev.relatedTarget) || this.props.renderOnInputBlur === "true") { // We call the function below only if the element that recovers the // focus after this blur is not an element of the we-list or if it // is an input event (preview). This allows to use the TAB key to go // from one input to another in the list. This behavior can be // cancelled if the widget has reloadOnInputBlur = "true" in its - // dataset. + // props. + const recordToUpdate = this.state.listRecords.find((rec) => + rec.id === this.state._toComparableId(ev.currentTarget.name) + ); + if (ev.currentTarget.closest(".o_we_list_record_name")) { + recordToUpdate.display_name = ev.currentTarget.value; + } else { + recordToUpdate.secondInputText = ev.currentTarget.value; + } const timeSinceMousedown = ev.timeStamp - this.mousedownTime; if (timeSinceMousedown < 500) { // Without this "setTimeOut", "click" events are not triggered when @@ -2536,81 +2854,61 @@ const ListUserValueWidget = UserValueWidget.extend({ this._notifyCurrentState(preview); } } - }, + } /** * @private */ _onWeListMousedown(ev) { this.mousedownTime = ev.timeStamp; - }, + } /** * @private * @param {Event} ev */ _onRemoveItemClick(ev) { - const minElements = this.el.dataset.allowEmpty ? 0 : 1; - if (ev.target.closest('table').querySelectorAll('tr').length > minElements) { - ev.target.closest('tr').remove(); - this._notifyCurrentState(); - } - }, - /** - * @override - */ - _onUserValueNotification(ev) { - const { widget, previewMode, prepare } = ev.data; - if (widget && widget === this.createWidget) { - if (widget.options.createMethod && widget.getValue(widget.options.createMethod)) { - return this._super(ev); - } - ev.stopPropagation(); - if (previewMode) { - return; + const minElements = this.props.allowEmpty ? 0 : 1; + if (this.state.listRecords.length > minElements) { + const removeId = this.state._toComparableId(ev.currentTarget.dataset.removeOption); + const recordIdx = this.state.listRecords.findIndex((record) => record.id === removeId); + if (recordIdx >= 0) { + this.state.listRecords.splice(recordIdx, 1); + } + if (this.state.hasDefault) { + const selectedIdx = this.state.selected.indexOf(removeId); + if (selectedIdx >= 0) { + this.state.selected.splice(selectedIdx, 1); + } } - prepare(); - const recordData = JSON.parse(widget.getMethodsParams('addRecord').recordData); - const { id, display_name } = recordData; - delete recordData.id; - delete recordData.display_name; - this._addItemToTable(id, display_name, recordData); this._notifyCurrentState(); } - return this._super(ev); - }, -}); - -const RangeUserValueWidget = UnitUserValueWidget.extend({ - tagName: 'we-range', - events: { - 'change input': '_onInputChange', - 'input input': '_onInputInput', - }, + } +} +registry.category("snippet_widgets").add("WeList", WeList); +class RangeUserValue extends UnitUserValue { + constructor() { + super(...arguments); + this._state.max = undefined; + this._state.displayValue = false; + } /** - * @override + * @type {number} */ - 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; - this.displayValue = this.el.dataset.displayRangeValue; - if (min > max) { - [min, max] = [max, min]; - this.input.classList.add('o_we_inverted_range'); - } - this._setInputAttributes(min, max, step); - this.containerEl.appendChild(this.input); - if (this.displayValue) { - this.outputEl = document.createElement('output'); - this.outputEl.classList.add('ms-2'); - this.containerEl.appendChild(this.outputEl); - } - - this._onInputChange = debounce(this._onInputChange, 100); - }, + get max() { + return this._state.max; + } + set max(value) { + this._state.max = value; + } + /** + * @type {string|number} + */ + get displayValue() { + return this._state.displayValue; + } + set displayValue(value) { + this._state.displayValue = value; + } //-------------------------------------------------------------------------- // Public @@ -2620,35 +2918,85 @@ const RangeUserValueWidget = UnitUserValueWidget.extend({ * @override */ loadMethodsData(validMethodNames) { - this._super(...arguments); + super.loadMethodsData(...arguments); for (const methodName of this._methodsNames) { const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; if (possibleValues.length > 1) { - this._setInputAttributes(0, possibleValues.length - 1, 1); + this.max = possibleValues.length - 1; break; } } - }, + } /** * @override */ async setValue(value, methodName) { - await this._super(...arguments); + await super.setValue(value, methodName); const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; - const inputValue = possibleValues.length > 1 ? possibleValues.indexOf(value) : this._value; - this.input.value = inputValue; - if (this.displayValue) { + const inputValue = possibleValues.length > 1 ? possibleValues.indexOf(value) : this.value; + if (this._data.displayRangeValue) { this._computeDisplayValue(inputValue); } - }, + } /** * @override */ getValue(methodName) { - const value = this._super(...arguments); + const value = super.getValue(...arguments); const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; return possibleValues.length > 1 ? possibleValues[+value] : value; - }, + } + /** + * @private + * @param {string} inputValue + */ + _computeDisplayValue(inputValue) { + if (this.toRatio) { + const inputValueAsNumber = Number(inputValue); + const ratio = inputValueAsNumber >= 0 ? 1 + inputValueAsNumber : 1 / (1 - inputValueAsNumber); + this.displayValue = `${ratio.toFixed(2)}x`; + } else if (this._data.displayRangeValueUnit) { + this.displayValue = inputValue + this._data.displayRangeValueUnit; + } else { + this.displayValue = inputValue; + } + } +} + +const RangeUserValueWidget = UnitUserValueWidget.extend({}); +class WeRange extends WeInput { + static template = "web_editor.WeRange"; + static StateModel = RangeUserValue; + static props = { + ...WeInput.props, + min: { type: String, optional: true }, + max: { type: String, optional: true }, + step: { type: String, optional: true }, + toRatio: { type: String, optional: true }, + displayRangeValue: { type: String, optional: true }, + }; + static defaultProps = { + min: "0", + max: "100", + step: "1", + toRatio: "", + displayRangeValue: "", + unit: "", + }; + setup() { + this._onInputChange = debounce(this._onInputChange, 100); + super.setup(); + this.state.toRatio = this.props.toRatio; + if (Number(this.props.min) > Number(this.props.max)) { + this.state.max = this.props.min; + this.min = this.props.max; + this.inverted = true; + } else { + this.min = this.props.min; + this.state.max ||= this.props.max; + this.inverted = false; + } + } //-------------------------------------------------------------------------- // Handlers @@ -2658,72 +3006,33 @@ const RangeUserValueWidget = UnitUserValueWidget.extend({ * @private */ _onInputChange(ev) { - this._value = ev.target.value; + this.state.value = ev.target.value; this._onUserValueChange(ev); - }, - /** - * @private - * @param {string} inputValue - */ - _computeDisplayValue(inputValue) { - if (this.el.dataset.toRatio) { - const inputValueAsNumber = Number(inputValue); - const ratio = inputValueAsNumber >= 0 ? 1 + inputValueAsNumber : 1 / (1 - inputValueAsNumber); - this.outputEl.value = `${ratio.toFixed(2)}x`; - } else if (this.el.dataset.displayRangeValueUnit) { - this.outputEl.value = inputValue + this.el.dataset.displayRangeValueUnit; - } else { - this.outputEl.value = inputValue; - } - }, + } /** * @private * @param {Event} ev */ _onInputInput(ev) { - this._value = ev.target.value; - if (this.displayValue) { - this._computeDisplayValue(this._value); + this.state.value = ev.target.value; + if (this.props.displayRangeValue) { + this.state._computeDisplayValue(this.state.value); } this._onUserValuePreview(ev); - }, - /** - * @private - */ - _setInputAttributes(min, max, step) { - this.input.setAttribute('min', min); - this.input.setAttribute('max', max); - this.input.setAttribute('step', step); - }, -}); - -const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ - className: (SelectUserValueWidget.prototype.className || '') + ' o_we_select_pager', - events: Object.assign({}, SelectUserValueWidget.prototype.events, { - 'click .o_pager_nav_btn': '_onClickScrollPage', - 'click .o_pager_nav_angle': '_onClickCloseMenu', - }), - /** - * @override - */ - async start() { - const _super = this._super.bind(this); + } +} +registry.category("snippet_widgets").add("WeRange", WeRange); - await _super(...arguments); - this.menuEl.classList.add('o_we_has_pager', 'position-fixed', 'top-0', 'end-0', 'z-1', 'rounded-0'); - this.menuTogglerEl.classList.add('o_we_toggler_pager'); +class WeSelectPager extends WeSelect { - this.pagerContainerEl = this.el.querySelector('.o_pager_container'); - this.__onScroll = throttleForAnimation(this._onScroll.bind(this)); - this.pagerContainerEl.addEventListener('scroll', this.__onScroll); - }, + static template = "web_editor.WeSelectPager"; /** * @override */ - destroy() { - this._super(...arguments); - this.pagerContainerEl.removeEventListener('scroll', this.__onScroll); - }, + setup() { + super.setup(); + this.__onScroll = useThrottleForAnimation(this._onScroll); + } //-------------------------------------------------------------------------- // Private @@ -2737,13 +3046,13 @@ const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ */ _adjustDropdownPosition() { return; - }, + } /** * @override */ _shouldIgnoreClick(ev) { - return !!ev.target.closest('.o_pager_nav') || this._super(...arguments); - }, + return !!ev.target.closest('.o_pager_nav') || super._shouldIgnoreClick(...arguments); + } //-------------------------------------------------------------------------- // Handlers @@ -2755,181 +3064,144 @@ const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ * @private */ _onClickScrollPage(ev) { - const navButtonEl = ev.currentTarget; + const navButtonEl = ev.target.closest("[data-scroll-to]"); + if (!navButtonEl) { + return; + } const attribute = navButtonEl.dataset.scrollTo; - const destinationOffset = this.menuEl.querySelector('.' + attribute).offsetTop; + const destinationOffset = this.menuRef.el.querySelector('.' + attribute).offsetTop; - const pagerNavEl = this.menuEl.querySelector('.o_pager_nav'); - this.pagerContainerEl.scrollTop = destinationOffset - pagerNavEl.offsetHeight; - }, + const pagerNavEl = this.menuRef.el.querySelector(".o_pager_nav"); + const pagerContainerEl = this.menuRef.el.querySelector(".o_pager_container"); + pagerContainerEl.scrollTop = destinationOffset - pagerNavEl.offsetHeight; + } /** * @private */ _onClickCloseMenu(ev) { - this.close(); - }, + this.state.close(); + } /** * @private */ _onScroll(ev) { - const pagerContainerHeight = this.pagerContainerEl.getBoundingClientRect().height; + const pagerContainerEl = this.menuRef.el.querySelector(".o_pager_container"); + const pagerContainerHeight = pagerContainerEl.getBoundingClientRect().height; // The threshold for when a menu element is defined as 'active' is half // of the container's height. This has a drawback as if a section // is too small it might never get `active` if it's the last section. - const threshold = this.pagerContainerEl.scrollTop + (pagerContainerHeight / 2); - const anchorElements = this.menuEl.querySelectorAll('[data-scroll-to]'); + const threshold = pagerContainerEl.scrollTop + (pagerContainerHeight / 2); + const anchorElements = this.menuRef.el.querySelectorAll('[data-scroll-to]'); for (const anchorEl of anchorElements) { const destination = anchorEl.getAttribute('data-scroll-to'); - const sectionEl = this.menuEl.querySelector(`.${destination}`); + const sectionEl = this.menuRef.el.querySelector(`.${destination}`); const nextSectionEl = sectionEl.nextElementSibling; anchorEl.classList.toggle('active', sectionEl.offsetTop < threshold && (!nextSectionEl || nextSectionEl.offsetTop > threshold)); } } -}); +} +registry.category("snippet_widgets").add("WeSelectPager", WeSelectPager); -const Many2oneUserValueWidget = SelectUserValueWidget.extend({ - className: (SelectUserValueWidget.prototype.className || '') + ' o_we_many2one', - events: Object.assign({}, SelectUserValueWidget.prototype.events, { - 'input .o_we_m2o_search input': '_onSearchInput', - 'keydown .o_we_m2o_search input': '_onSearchKeydown', - 'click .o_we_m2o_search_more': '_onSearchMoreClick', - }), - // Data-attributes that will be read into `this.options` on init and not +const Many2oneUserValueWidget = SelectUserValueWidget.extend({}); + +export class Many2oneUserValue extends SelectUserValue { + // Props that will be read into `this.options` on setup and not // transfered to inner buttons. // `domain` is the static part of the domain used in searches, not // depending on already selected ids and other filters. - configAttributes: [ + static configProps = [ "model", "fields", "limit", "domain", "callWith", "createMethod", "filterInModel", "filterInField", "nullText", "defaultMessage", - ], + ]; + + constructor() { + super(...arguments); + this.orm = serviceCached(this.env, "orm"); + this._state.records = []; + this._state.hasMore = false; - /** - * @override - */ - init(parent, title, options, $target) { - this.afterSearch = []; this.displayNameCache = {}; - const {dataAttributes} = options; - Object.assign(options, { + this.options = Object.assign({}, this._data, { limit: '5', fields: '[]', domain: '[]', callWith: 'id', + dataAttributes: {}, }); - this.configAttributes.forEach(attr => { - if (dataAttributes.hasOwnProperty(attr)) { - options[attr] = dataAttributes[attr]; - delete dataAttributes[attr]; - } - }); - options.limit = parseInt(options.limit); - options.fields = JSON.parse(options.fields); - if (!options.fields.includes('display_name')) { - options.fields.push('display_name'); - } - options.domain = JSON.parse(options.domain); - options.domainComponents = {}; - options.nullText = $target[0].dataset.nullText || - JSON.parse($target[0].dataset.oeContactOptions || '{}')['null_text']; - - this.orm = serviceCached(this.bindService("orm")); + this._updateOptions(this._data); + this.options.domainComponents = {}; - return this._super(...arguments); - }, + this.constructorPatch(); + } + constructorPatch() {} /** - * @override + * @type {Object[]} */ - async start() { - await this._super(...arguments); - - this.inputEl = document.createElement('input'); - this.inputEl.setAttribute('placeholder', _t("Search for records...")); - const searchEl = document.createElement('div'); - searchEl.classList.add('o_we_m2o_search'); - searchEl.appendChild(this.inputEl); - this.menuEl.appendChild(searchEl); - - this.searchMore = document.createElement('div'); - this.searchMore.classList.add('o_we_m2o_search_more'); - this.searchMore.textContent = _t("Search more..."); - this.searchMore.title = _t("Search to show more records"); - - if (this.options.createMethod) { - this.createInput = new InputUserValueWidget(this, undefined, { - classes: ['o_we_large'], - dataAttributes: { noPreview: 'true' }, - }, this.$target); - this.createButton = new ButtonUserValueWidget(this, undefined, { - classes: ['flex-grow-0'], - dataAttributes: { - noPreview: 'true', - [this.options.createMethod]: '', // Value through getValue. - }, - childNodes: [document.createTextNode(_t("Create"))], - }, this.$target); - // Override isActive so it doesn't show up in toggler - this.createButton.isActive = () => false; - - await Promise.all([ - this.createInput.appendTo(document.createDocumentFragment()), - this.createButton.appendTo(document.createDocumentFragment()), - ]); - this.registerSubWidget(this.createInput); - this.registerSubWidget(this.createButton); - this.createWidget = _buildRowElement('', { - classes: ['o_we_full_row', 'o_we_m2o_create', 'p-1'], - childNodes: [this.createInput.el, this.createButton.el], - }); - } + get records() { + return this._state.records; + } + set records(value) { + this._state.records = value; + } + /** + * @type {boolean} + */ + get hasMore() { + return this._state.hasMore; + } - return this._search(''); - }, /** * @override */ async setValue(value, methodName) { - await this._super(...arguments); - if (this.menuTogglerEl.textContent === this.PLACEHOLDER_TEXT.toString()) { + await super.setValue(...arguments); + if (this._state.toggler.textContent === this.constructor.PLACEHOLDER_TEXT.toString()) { // The currently selected value is not present in the search, need to read // its display name. if (value !== '') { // FIXME: value may not be an id if callWith is specified! - this.menuTogglerEl.textContent = await this._getDisplayName(parseInt(value)); + this._state.toggler.textContent = await this._getDisplayName(parseInt(value)); } else { - this.menuTogglerEl.textContent = this.options.defaultMessage || _t("Choose a record..."); + this._state.toggler.textContent = this.options.defaultMessage || _t("Choose a record..."); } } - }, + } /** * @override */ getValue(methodName) { - if (methodName === this.options.createMethod && this.createInput) { - return this.createInput._value; + if (methodName === this.options.createMethod && this.createInputValue) { + return this.createInputValue; } - return this._super(...arguments); - }, + return super.getValue(...arguments); + } /** - * Prevents double widget instanciation for we-buttons that have been - * created manually by _search (container widgets will have their innner - * html searched for userValueWidgets to instanciate during option startup) - * * @override */ - isContainer() { - return false; - }, + registerUserValue(userValue) { + // Get a reference to the createButton and make sure it is always + // inactive. + if (this._data.createMethod) { + if ( + !this.createButton + && userValue instanceof ButtonUserValue + && userValue._data[this._data.createMethod] !== undefined + ) { + this.createButton = userValue; + this.createButton.isActive = () => false; + } + } + super.registerUserValue(userValue); + } /** * @override */ - open() { - if (this.createInput) { - this.createInput.setValue(''); - } - return this._super(...arguments); - }, + close() { + super.close(...arguments); + this._search(""); + } /** * Updates the domain with defined inclusive filter to make sure that only * records that are linked to specific records are retrieved. @@ -2956,12 +3228,37 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ if (allowedIds.size) { this.options.domainComponents.filterInModel = ['id', 'in', [...allowedIds]]; } - }, + } //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- + _updateOptions(props) { + for (const prop in props) { + // TODO: @owl-options: remove undefined props in UserValue directly? + if (props[prop] === undefined) { + continue; + } + if (this.constructor.configProps.includes(prop)) { + this.options[prop] = props[prop]; + } else { + this.options.dataAttributes[prop] = props[prop]; + } + } + this.options.limit = parseInt(this.options.limit); + if (typeof this.options.fields === "string") { + this.options.fields = JSON.parse(this.options.fields); + } + if (!this.options.fields.includes('display_name')) { + this.options.fields.push('display_name'); + } + if (typeof this.options.domain === "string") { + this.options.domain = JSON.parse(this.options.domain); + } + this.options.nullText = this.$target[0].dataset.nullText || + JSON.parse(this.$target[0].dataset.oeContactOptions || '{}')['null_text']; + } /** * Searches the database for corresponding records and updates the dropdown * @@ -2981,18 +3278,7 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ recTuples.map(([id, _name]) => id), this.options.fields ); - // Remove select options. - this._userValueWidgets.filter(widget => { - return widget instanceof ButtonUserValueWidget && - !widget.isDestroyed() && - widget.el.parentElement.matches('we-selection-items'); - }).forEach(button => { - if (button.isPreviewed()) { - button.notifyValueChange('reset'); - } - button.destroy(); - }); - this._userValueWidgets = this._userValueWidgets.filter(widget => !widget.isDestroyed()); + if (this.options.nullText && this.options.nullText.toLowerCase().includes(needle.toLowerCase())) { // Beware of RPC cache. @@ -3004,55 +3290,13 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ this.displayNameCache[record.id] = record.display_name; }); - await Promise.all(records.slice(0, this.options.limit).map(async record => { - // Copy over the data-attributes from the main element, and default the value - // to the callWith field of the record so that if it's a method, it will - // be called with that value - const buttonDataAttributes = Object.assign({}, this.options.dataAttributes); - Object.keys(buttonDataAttributes).forEach(key => { - buttonDataAttributes[key] = buttonDataAttributes[key] || record[this.options.callWith]; - }); - // REMARK: this syntax is very similar to React.createComponent, maybe we could - // write a transformer like there is for JSX? - const buttonWidget = new ButtonUserValueWidget(this, undefined, { - dataAttributes: Object.assign({recordData: JSON.stringify(record)}, buttonDataAttributes), - childNodes: [document.createTextNode(record.display_name)], - }, this.$target); - this.registerSubWidget(buttonWidget); - await buttonWidget.appendTo(this.menuEl); - if (this._methodsNames) { - buttonWidget.loadMethodsData(this._methodsNames); - } - })); - // Load methodsData for new buttons if possible. It will not be possible - // when the widget is first created (as this._methodsNames will be undefined) - // but the snippetOption lifecycle will load the methods data explicitely - // just after creating the widget - if (this._methodsNames) { - this._methodsNames.forEach(methodName => { - this.setValue(this._value, methodName); - }); - } - - const hasMore = records.length > this.options.limit; - if (hasMore) { - this.menuEl.appendChild(this.searchMore); - this.searchMore.classList.remove('d-none'); - } else { - this.searchMore.classList.add('d-none'); - } - - if (this.createWidget) { - this.menuEl.appendChild(this.createWidget); - } + this.records = records.slice(0, this.options.limit); + this._state.hasMore = records.length > this.options.limit; - this.waitingForSearch = false; - this.afterSearch.forEach(cb => cb()); - this.afterSearch = []; if (this.options.nullText && !this.getValue()) { this.setValue(0); } - }, + } /** * Returns the domain to use for the search. * @@ -3060,7 +3304,7 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ */ async _getSearchDomain() { return this.options.domain; - }, + } /** * Returns the display name for a given record. * @@ -3071,7 +3315,136 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ this.displayNameCache[recordId] = (await this.orm.read(this.options.model, [recordId], ['display_name']))[0].display_name; } return this.displayNameCache[recordId]; - }, + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + userValueNotification(params) { + const { widget } = params; + if (widget && widget === this.createButton && !params.previewMode) { + // When the create button is clicked, make sure the state value + // matches the value of the input element because it might have been + // removed after closing the dropdown. + if (this.displayedValue && !this.createInputValue) { + this.createInputValue = this.displayedValue; + delete this.displayedValue; + } + // Prevent creating a record if the input is empty. + if (!this.createInputValue) { + return; + } + } + if (widget !== this.createButton && this.createInputValue) { + // Remove the createInputValue if something else than the + // createButton was clicked or hovered, so that it doesn't create a + // record. We reset it after the value handling. + this.displayedValue = this.createInputValue; + this.createInputValue = ""; + } + super.userValueNotification(...arguments); + // Reset the createInputValue after value handling, only with + // previewMode === true or 'reset'. + if (this.displayedValue && params.previewMode) { + this.createInputValue = this.displayedValue; + delete this.displayedValue; + } + } +} + +class WeMany2one extends WeSelect { + static template = "web_editor.WeMany2one"; + static StateModel = Many2oneUserValue; + static components = { ...WeSelect.components, WeRow, WeInput, WeButton }; + static configProps = { + model: { type: String }, + domain: { type: String, optional: true }, + fields: { type: String, optional: true }, + limit: { type: String, optional: true }, + callWith: { type: String, optional: true }, + createMethod: { type: String, optional: true }, + filterInModel: { type: String, optional: true }, + filterInField: { type: String, optional: true }, + nullText: { type: String, optional: true }, + defaultMessage: { type: String, optional: true }, + }; + static props = { + ...WeSelect.props, + ...this.configProps, + }; + + setup() { + super.setup(); + const keepLast = new KeepLast(); + this.afterSearch = []; + + onWillStart(async () => { + await this.state._search(""); + }); + + onWillUpdateProps(async (nextProps) => { + this.state._updateOptions(nextProps); + // Make sure the update is reflected in the prefetched records. + await this.state._search(""); + }); + + useEffect( + () => { + // Load methodsData for WeButtons generated from + // this.state.records. + if (this.state._methodsNames) { + Object.values(this.state._subValues).forEach(widget => { + if (widget instanceof ButtonUserValue && widget._data.recordData) { + widget.loadMethodsData([...this.state._methodsNames]); + } + }); + if (this.state.value !== undefined) { + this.state._methodsNames.forEach(async (methodName) => + await keepLast.add(this.state.setValue(this.state.value, methodName)) + ); + } + } + this.waitingForSearch = false; + this.afterSearch.forEach(cb => cb()); + this.afterSearch = []; + }, + () => [this.state.records] + ); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getAllClasses() { + return { + ...super.getAllClasses(), + "position-static": this.props.createUserValue, + }; + } + /** + * Copy over the props from the main element, and default the value + * to the callWith field of the record so that if it's a method, it will + * be called with that value + * + * @param {Object} record + * @returns {Object} + */ + getButtonProps(record) { + const buttonProps = Object.assign({}, this.state.options.dataAttributes); + Object.keys(buttonProps).forEach((key) => { + buttonProps[key] = buttonProps[key] || record[this.state.options.callWith].toString(); + }); + buttonProps.recordData = JSON.stringify(record); + return buttonProps; + } //-------------------------------------------------------------------------- // Handlers @@ -3087,8 +3460,16 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ ev.stopPropagation(); return; } - return this._super(...arguments); - }, + if (ev.target.closest("we-button")) { + this.menuRef.el.querySelector(".o_we_m2o_search input").value = ""; + } + // Reset the input value when creating a new record. The state value is + // reset on `setValue` of the ListUserValue. + if (ev.target.closest(".o_we_m2o_create") && ev.target.closest("we-button")) { + this.menuRef.el.querySelector(".o_we_m2o_create input").value = ""; + } + return super._onClick(...arguments); + } /** * Handles changes to the search bar. * @@ -3100,10 +3481,10 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ // is one that is ongoing (ie currently waiting for the debounce or RPC) clearTimeout(this.searchIntent); this.waitingForSearch = true; - this.searchIntent = setTimeout(() => { - this._search(ev.target.value); + this.searchIntent = setTimeout(async () => { + await this.state._search(ev.target.value); }, 500); - }, + } /** * Selects the first option when pressing enter in the search input. * @@ -3114,7 +3495,7 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ return; } const action = () => { - const firstButton = this.menuEl.querySelector(':scope > we-button'); + const firstButton = this.menuRef.el.querySelector(':scope > we-button'); if (firstButton) { firstButton.click(); } @@ -3124,162 +3505,107 @@ const Many2oneUserValueWidget = SelectUserValueWidget.extend({ } else { action(); } - }, + } /** * Focuses the search input when clicking on the "Search more..." button. * * @private */ _onSearchMoreClick(ev) { - this.inputEl.focus(); - }, + this.menuRef.el.querySelector(".o_we_m2o_search input").focus(); + } /** - * @override + * @private */ - _onUserValueNotification(ev) { - const { widget } = ev.data; - if (widget && widget === this.createInput) { - ev.stopPropagation(); - return; - } - if (widget && widget === this.createButton) { - // When the create button is clicked, make sure the text - // value is restored from the actual input element because - // it might have been removed when hovering existing tags. - // TODO review this, there is probably better to do - this.createInput._value = this.createInput.el.querySelector('input').value; - if (!this.createInput._value) { - ev.stopPropagation(); - } - return; - } - if (widget !== this.createButton && this.createInput) { - this.createInput._value = ''; - } - return this._super(ev); - }, -}); + _onCreateInputInput(ev) { + this.state.createInputValue = ev.currentTarget.value; + } +} +registry.category("snippet_widgets").add("WeMany2one", WeMany2one); -const Many2manyUserValueWidget = UserValueWidget.extend({ - configAttributes: ['model', 'recordId', 'm2oField', 'createMethod', 'fakem2m', 'filterIn'], - /** - * @override - */ - init(parent, title, options, $target) { - const { dataAttributes } = options; - this.configAttributes.forEach(attr => { - if (dataAttributes.hasOwnProperty(attr)) { - options[attr] = dataAttributes[attr]; - delete dataAttributes[attr]; +const Many2manyUserValueWidget = UserValueWidget.extend({}); + +class Many2manyUserValue extends UserValue { + static configProps = ['model', 'recordId', 'm2oField', 'createMethod', 'fakem2m', 'filterIn']; + + constructor() { + super(...arguments); + this._state.m2oProps = {}; + + for (const prop in this._data) { + if ( + !this.constructor.configProps.includes(prop) + && Many2oneUserValue.configProps.includes(prop) + ) { + this.m2oProps[prop] = this._data[prop]; } - }); - this.filterIn = options.filterIn !== undefined; + } + + this.filterIn = this._data.filterIn !== undefined; if (this.filterIn) { // Transfer filter-in values to child m2o. - dataAttributes.filterInModel = options.model; - dataAttributes.filterInField = options.m2oField; - } - this.orm = this.bindService("orm"); - this.fields = this.bindService("field"); - return this._super(...arguments); - }, - /** - * @override - */ - async willStart() { - await this._super(...arguments); - // If the widget does not have a real m2m field in the database - // We do not need to fetch anything from the DB - if (this.options.fakem2m) { - this.m2oModel = this.options.model; - return; + this.m2oProps.filterInModel = this._data.model; + this.m2oProps.filterInField = this._data.m2oField; } - const { model, recordId, m2oField } = this.options; - const [record] = await this.orm.read(model, [parseInt(recordId)], [m2oField]); - const selectedRecordIds = record[m2oField]; - // TODO: handle no record - const modelData = await this.fields.loadFields(model, { fieldNames: [m2oField] }); - // TODO: simultaneously fly both RPCs - this.m2oModel = modelData[m2oField].relation; - this.m2oName = modelData[m2oField].field_description; // Use as string attr? - - const selectedRecords = await this.orm.read(this.m2oModel, selectedRecordIds, ['display_name']); - // TODO: reconcile the fact that this widget sets its own initial value - // instead of it coming through setValue(_computeWidgetState) - this._value = JSON.stringify(selectedRecords); - }, - /** - * @override - */ - async start() { - this.el.classList.add('o_we_m2m'); - const m2oDataAttributes = Object.entries(this.options.dataAttributes).filter(([attrName]) => { - return Many2oneUserValueWidget.prototype.configAttributes.includes(attrName); - }); - m2oDataAttributes.push( - ['model', this.m2oModel], - ['addRecord', ''], - ['createMethod', this.options.createMethod], - ); - // Don't register this one as a subWidget because it will be a subWidget - // of the listWidget - this.createWidget = new Many2oneUserValueWidget(null, undefined, { - dataAttributes: Object.fromEntries(m2oDataAttributes), - }, this.$target); - - this.listWidget = registerUserValueWidget('we-list', this, undefined, { - dataAttributes: { unsortable: 'true', notEditable: 'true', allowEmpty: 'true' }, - createWidget: this.createWidget, - }, this.$target); - await this.listWidget.appendTo(this.containerEl); - - // Make this.el the select's offsetParent so the we-selection-items has - // the correct width - this.listWidget.el.querySelector('we-select').style.position = 'static'; - this.el.style.position = 'relative'; - }, + } + get m2oProps() { + return this._state.m2oProps; + } + set m2oProps(value) { + this._state.m2oProps = value; + } + /** * Only allow to fetch/select records which are linked (via `m2oField`) to the * specified records. * * @param {integer[]} linkedRecordsIds * @returns {Promise} - * @see Many2oneUserValueWidget.setFilterInDomainIds + * @see Many2oneUserValue.setFilterInDomainIds */ async setFilterInDomainIds(linkedRecordsIds) { if (this.filterIn) { - return this.listWidget.createWidget.setFilterInDomainIds(linkedRecordsIds); + return this.listUserValue.createUserValue.setFilterInDomainIds(linkedRecordsIds); } - }, + } /** * @override */ loadMethodsData(validMethodNames, ...rest) { // TODO: check that addRecord is still needed. - this._super(['addRecord', ...validMethodNames], ...rest); - this._methodsNames = this._methodsNames.filter(name => name !== 'addRecord'); - }, + super.loadMethodsData(['addRecord', ...validMethodNames], ...rest); + } /** * @override */ setValue(value, methodName) { - if (methodName === this.options.createMethod) { - return this.createWidget.setValue(value, methodName); + if (methodName === this._data.createMethod) { + return this.listUserValue?.createUserValue.setValue(value, methodName); } if (!value) { // TODO: why do we need this. - value = this._value; + value = this.value; } - this._super(value, methodName); - this.listWidget.setValue(this._value); - }, + super.setValue(value, methodName); + this.listUserValue?.setValue(this.value); + } /** * @override */ getValue(methodName) { - return this.listWidget.getValue(methodName); - }, + return this.listUserValue.getValue(methodName); + } + /** + * @override + */ + registerUserValue(userValue) { + if (!this.listUserValue && userValue instanceof ListUserValue) { + this.listUserValue = userValue; + } + super.registerUserValue(userValue); + this._methodsNames.delete("addRecord"); + } //-------------------------------------------------------------------------- // Private @@ -3288,85 +3614,198 @@ const Many2manyUserValueWidget = UserValueWidget.extend({ /** * @override */ - _onUserValueNotification(ev) { - const { widget, previewMode } = ev.data; + userValueNotification(params) { + const { widget, previewMode } = params; if (!widget) { - return this._super(ev); + return super.userValueNotification(...arguments); } - if (widget === this.listWidget) { - ev.stopPropagation(); - this._value = widget._value; + if (widget === this.listUserValue) { + this.value = widget.value; this.notifyValueChange(previewMode); } - }, -}); + } +} + +class WeMany2many extends UserValueComponent { + static isContainer = true; + static template = "web_editor.WeMany2many"; + static StateModel = Many2manyUserValue; + static components = { WeList, WeMany2one, WeTitle }; + static configProps = { + // "model" and "createMethod" are already part of the WeMany2one props. + fakem2m: { type: String, optional: true }, + filterIn: { type: String, optional: true }, + m2oField: { type: String, optional: true }, + recordId: { type: String, optional: true }, + }; + static props = { + ...UserValueComponent.props, + ...WeMany2one.configProps, + ...this.configProps, + }; + + setup() { + super.setup(...arguments); + this.orm = useService("orm"); + this.field = useService("field"); + + onWillStart(async () => { + let m2oModel; + // If the widget does not have a real m2m field in the database + // We do not need to fetch anything from the DB + if (this.props.fakem2m) { + m2oModel = this.props.model; + } else { + const { model, recordId, m2oField } = this.props; + const [record] = await this.orm.read(model, [parseInt(recordId)], [m2oField]); + const selectedRecordIds = record[m2oField]; + // TODO: handle no record + const modelData = await this.field.loadFields(model, { fieldNames: [m2oField] }); + m2oModel = modelData[m2oField].relation; + + const selectedRecords = await this.orm.read(m2oModel, selectedRecordIds, ['display_name']); + this.state.value = JSON.stringify(selectedRecords); + } + + Object.assign(this.state.m2oProps, { + model: m2oModel, + addRecord: '', + createMethod: this.props.createMethod, + }); + }); + } +} +registry.category("snippet_widgets").add("WeMany2many", WeMany2many); -const userValueWidgetsRegistry = { +// TODO: @owl-options properly remove this registry +export 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-datepicker': DatePickerUserValueWidget, 'we-list': ListUserValueWidget, 'we-imagepicker': ImagepickerUserValueWidget, 'we-videopicker': VideopickerUserValueWidget, 'we-range': RangeUserValueWidget, - 'we-select-pager': SelectPagerUserValueWidget, 'we-many2one': Many2oneUserValueWidget, 'we-many2many': Many2manyUserValueWidget, }; +/** + * This component is responsible for rendering the widgets. Once it's mounted + * it means all "UserValue" states have been created, and the templates are + * accessible. So it notifies the SnippetsMenu that the initial "updateUI" is + * ready to be done. + * @TODO @owl-options: If an option renders a widget **after** the initial + * onMounted, the SnippetsMenu will not have a computed state for its widgets. + * It should therefore ask for a new updateUI. This should probably be improved. + * To discuss. + */ +export class SnippetOptionComponent extends Component { + + static template = "web_editor.SnippetOptionComponent"; + + static props = { + snippetOption: Object, + onOptionMounted: Function, + }; + + setup() { + this.$overlay = this.props.snippetOption.instance.$overlay; + this.renderContext = useState(this.props.snippetOption.instance.renderContext); + // When a component is mounted or unmounted, the state of other + // components might be impacted. (i.e. dependencies behaving + // differently when a component is in the DOM or when it isn't) + this.updateUI = false; + + useSubEnv({ + snippetOption: this.props.snippetOption.instance, + userValueNotification: this.props.snippetOption.instance.onUserValueUpdate, + registerUserValue: (userValue) => { + this.updateUI = true; + return this.props.snippetOption.instance.registerUserValue(userValue); + }, + unregisterUserValue: (userValue) => { + this.updateUI = true; + return this.props.snippetOption.instance.unregisterUserValue(userValue); + }, + renderContext: this.renderContext, + registerLayoutElement: (layoutElement) => { + return this.props.snippetOption.instance.registerLayoutElement(layoutElement); + }, + $target: this.props.snippetOption.instance.$target, + }); + + onMounted(() => { + this.props.onOptionMounted(); + this.updateUI = false; + }); + + onPatched(() => { + if (this.updateUI) { + this.updateUI = false; + this.props.onOptionMounted(); + } + }); + } +} /** * 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: { +export class SnippetOption { + static events = { 'click .o_we_collapse_toggler': '_onCollapseTogglerClick', - }, - custom_events: { + }; + static 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, + static isTopOption = false; /** * Indicates if the option should be the first one displayed in the button * group at the top of the options panel, next to the clone/remove button. * * @type {boolean} */ - isTopFirstOption: false, + static isTopFirstOption = false; /** * Forces the target to not be possible to remove. It will also hide the * clone button. * * @type {boolean} */ - forceNoDeleteButton: false, + static forceNoDeleteButton = false; /** * The option needs the handles overlay to be displayed on the snippet. * * @type {boolean} */ - displayOverlayOptions: false, + static displayOverlayOptions = false; /** * Forces the target to be duplicable. * * @type {boolean} */ - forceDuplicateButton: false, + static forceDuplicateButton = false; + /** + * Default component to use for rendering. + * Can be overridden when registering a snippet option in snippet_options registry. + * + * @type {Class} + */ + static defaultRenderingComponent = SnippetOptionComponent; /** * The option `$el` is supposed to be the associated DOM UI element. @@ -3376,42 +3815,59 @@ const SnippetOptionWidget = Widget.extend({ * * @constructor */ - init: function (parent, $uiElements, $target, $overlay, data, options) { - this._super.apply(this, arguments); - - this.$originalUIElements = $uiElements; + constructor({ editor, $target, $overlay, data, options, callbacks }) { + this.env = options.env; this.$target = $target; this.$overlay = $overlay; this.data = data; this.options = options; + // TODO: @owl-options better name or do it differently? It's to replace + // what was previously done by trigger_up's. + this.callbacks = callbacks; this.className = 'snippet-option-' + this.data.optionName; this.ownerDocument = this.$target[0].ownerDocument; + /** @type {UserValue[]} */ + this._userValues = {}; + this._layoutElements = []; this._userValueWidgets = []; this._actionQueues = new Map(); - this.dialog = this.bindService("dialog"); - }, + this.dialog = this.env.services.dialog; + // Passed as props to components so bind it here, that way + // it will always have the same reference. + this.onUserValueUpdate = this._onUserValueUpdate.bind(this); + + this.constructorPatch(); + } /** - * @override + * To be patched. */ - willStart: async function () { - await this._super(...arguments); - return this._renderOriginalXML().then(uiFragment => { - this.uiFragment = uiFragment; - }); - }, + constructorPatch() {} + /** + * Called after the constructor. Allows the option to do async stuff + * before the widgets are mounted. + */ + async willStart() { + const context = await this._getRenderContext(); + this.renderContext = reactive({...context}); + if (this.data.groups) { + for (const group of this.data.groups) { + this.isRestrictedGroup ||= !(await user.hasGroup(group)); + } + } + } + destroy() {} /** * @override */ - renderElement: function () { - this._super(...arguments); + renderElement() { 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). @@ -3419,7 +3875,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @returns {Promise|undefined} */ - async onFocus() {}, + async onFocus() {} /** * 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 @@ -3432,7 +3888,7 @@ const SnippetOptionWidget = Widget.extend({ * the main element has been built). * @returns {Promise|undefined} */ - async onBuilt(options) {}, + async onBuilt(options) {} /** * Called when the parent edition overlay is removed from the associated * snippet (another snippet enters edition for example). @@ -3440,7 +3896,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @returns {Promise|undefined} */ - async onBlur() {}, + async onBlur() {} /** * Called when the associated snippet is the result of the cloning of * another snippet (so `this.$target` is a cloned element). @@ -3452,20 +3908,20 @@ const SnippetOptionWidget = Widget.extend({ * was cloned (so not a clone of a child of this main element that * was cloned) */ - onClone: function (options) {}, + onClone(options) {} /** * Called when the associated snippet is moved to another DOM location. * * @abstract */ - onMove: function () {}, + onMove() {} /** * Called when the associated snippet is about to be removed from the DOM. * * @abstract * @returns {Promise|undefined} */ - onRemove: async function () {}, + async onRemove() {} /** * Called when the target is shown, only meaningful if the target was hidden * at some point (typically used for 'invisible' snippets). @@ -3473,7 +3929,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @returns {Promise|undefined} */ - onTargetShow: async function () {}, + async onTargetShow() {} /** * Called when the target is hidden (typically used for 'invisible' * snippets). @@ -3481,7 +3937,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @returns {Promise|undefined} */ - onTargetHide: async function () {}, + async onTargetHide() {} /** * Called when the template which contains the associated snippet is about * to be saved. @@ -3489,7 +3945,7 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @return {Promise|undefined} */ - cleanForSave: async function () {}, + async cleanForSave() {} /** * Called when the associated snippet UI needs to be cleaned (e.g. from * visual effects like previews). @@ -3498,15 +3954,32 @@ const SnippetOptionWidget = Widget.extend({ * @abstract * @return {Promise|undefined} */ - cleanUI: async function () {}, + async cleanUI() {} /** * Adds the given widget to the known list of user value widgets * - * @param {UserValueWidget} widget + * @param {UserValue} userValue */ - registerSubWidget(widget) { - this._userValueWidgets.push(widget); - }, + registerUserValue(userValue) { + this._userValues[userValue.id] = (userValue); + userValue.loadMethodsData(this.getMethodsNames()); + } + /** + * Removes the give user value to the known list of user values. + * + * @param {UserValue} userValue + */ + unregisterUserValue(userValue) { + delete this._userValues[userValue.id]; + } + /** + * Register a layout element to hide them if no component is visible + * within it. + * @params {Object} layoutElement + */ + registerLayoutElement(layoutElement) { + this._layoutElements.push(layoutElement); + } //-------------------------------------------------------------------------- // Options @@ -3527,7 +4000,7 @@ const SnippetOptionWidget = Widget.extend({ * @param {Object} params * @returns {Promise|undefined} */ - selectClass: function (previewMode, widgetValue, params) { + selectClass(previewMode, widgetValue, params) { for (const classNames of params.possibleValues) { if (classNames) { this.$target[0].classList.remove(...classNames.trim().split(/\s+/g)); @@ -3536,7 +4009,7 @@ const SnippetOptionWidget = Widget.extend({ 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 @@ -3547,10 +4020,10 @@ const SnippetOptionWidget = Widget.extend({ * @param {Object} params * @returns {Promise|undefined} */ - selectDataAttribute: function (previewMode, widgetValue, params) { + selectDataAttribute(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 @@ -3561,14 +4034,14 @@ const SnippetOptionWidget = Widget.extend({ * @param {Object} params * @returns {Promise|undefined} */ - selectAttribute: function (previewMode, widgetValue, params) { + selectAttribute(previewMode, widgetValue, params) { const value = this._selectAttributeHelper(widgetValue, params); if (value) { this.$target[0].setAttribute(params.attributeName, value); } else { this.$target[0].removeAttribute(params.attributeName); } - }, + } /** * Default option method which allows to select a value and set it on the * associated snippet as a property. The name of the property is @@ -3578,13 +4051,13 @@ const SnippetOptionWidget = Widget.extend({ * @param {string} widgetValue * @param {Object} params */ - selectProperty: function (previewMode, widgetValue, params) { + selectProperty(previewMode, widgetValue, params) { if (!params.propertyName) { throw new Error('Property name missing'); } const value = this._selectValueHelper(widgetValue, params); this.$target[0][params.propertyName] = 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 @@ -3603,7 +4076,7 @@ const SnippetOptionWidget = Widget.extend({ * an effect. * @returns {Promise|undefined} */ - selectStyle: async function (previewMode, widgetValue, params) { + async selectStyle(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 @@ -3761,7 +4234,7 @@ const SnippetOptionWidget = Widget.extend({ } _restoreTransitions(); - }, + } /** * Sets a color combination. * @@ -3777,7 +4250,7 @@ const SnippetOptionWidget = Widget.extend({ this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`); } } - }, + } //-------------------------------------------------------------------------- // Public @@ -3789,21 +4262,21 @@ const SnippetOptionWidget = Widget.extend({ * * @override */ - $: function () { + $() { return this.$target.find.apply(this.$target, arguments); - }, + } /** * Closes all user value widgets. */ - closeWidgets: function () { - this._userValueWidgets.forEach(widget => widget.close()); - }, + closeWidgets() { + Object.values(this._userValues).forEach(widget => widget.close()); + } /** * @param {string} name * @returns {UserValueWidget|null} */ - findWidget: function (name) { - for (const widget of this._userValueWidgets) { + findWidget(name) { + for (const widget of Object.values(this._userValues)) { if (widget.getName() === name) { return widget; } @@ -3813,7 +4286,7 @@ const SnippetOptionWidget = Widget.extend({ } } 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 @@ -3822,13 +4295,13 @@ const SnippetOptionWidget = Widget.extend({ * @param {string} name - an identifier for a type of update * @param {*} data */ - notify: function (name, data) { + notify(name, data) { // We prefer to avoid refactoring this notify mechanism to make it // asynchronous because the upcoming conversion to owl might remove it. 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 @@ -3840,9 +4313,21 @@ const SnippetOptionWidget = Widget.extend({ * @param {jQuery} $target - the new target element * @returns {Promise} */ - setTarget: function ($target) { + setTarget($target) { this.$target = $target; - }, + } + getMethodsNames() { + // TODO: @owl-options either add all possible method or find a way to compute it. + function getMethods(obj) { + if (!obj || obj === Object) { + return []; + } + const properties = Object.getOwnPropertyNames(obj); + const methods = properties.filter(name => typeof obj[name] === "function"); + return [...methods, ...getMethods(Object.getPrototypeOf(obj))]; + } + return getMethods(this); + } /** * Updates the UI. For widget update, @see _computeWidgetState. * @@ -3858,11 +4343,15 @@ const SnippetOptionWidget = Widget.extend({ // 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 => { + const useValueStates = Object.values(this._userValues); + const proms = useValueStates.map(async (userValue) => { + if (!userValue.started) { + await userValue.start(); + } // Update widget value (for each method) - const methodsNames = widget.getMethodsNames(); + const methodsNames = userValue.getMethodsNames(); for (const methodName of methodsNames) { - const params = widget.getMethodsParams(methodName); + const params = userValue.getMethodsParams(methodName); let obj = this; if (params.applyTo) { @@ -3878,7 +4367,7 @@ const SnippetOptionWidget = Widget.extend({ continue; } const normalizedValue = this._normalizeWidgetValue(value); - await widget.setValue(normalizedValue, methodName); + await userValue.setValue(normalizedValue, methodName); } }); await Promise.all(proms); @@ -3886,22 +4375,22 @@ const SnippetOptionWidget = Widget.extend({ 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(); + async updateUIVisibility() { + const proms = Object.values(this._userValues).map(async userValue => { + const params = userValue.getMethodsParams(); let obj = this; if (params.applyTo) { const $firstSubTarget = this.$(params.applyTo).eq(0); if (!$firstSubTarget.length) { - widget.toggleVisibility(false); + userValue.toggleVisibility(false); return; } obj = createPropertyProxy(this, '$target', $firstSubTarget); @@ -3910,23 +4399,23 @@ const SnippetOptionWidget = Widget.extend({ // 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]; + const allSubValues = [userValue]; let i = 0; - while (i < allSubWidgets.length) { - allSubWidgets.push(...allSubWidgets[i]._userValueWidgets); + while (i < allSubValues.length) { + allSubValues.push(...Object.values(allSubValues[i]._subValues)); i++; } - const proms = allSubWidgets.map(async widget => { - const show = await this._computeWidgetVisibility.call(obj, widget.getName(), params); + const proms = allSubValues.map(async userValue => { + const show = await this._computeWidgetVisibility.call(obj, userValue.getName(), params); if (!show) { - widget.toggleVisibility(false); + userValue.toggleVisibility(false); return; } - const dependencies = widget.getDependencies(); + const dependencies = userValue.getDependencies(); if (dependencies.length === 1 && dependencies[0] === 'fake') { - widget.toggleVisibility(false); + userValue.toggleVisibility(false); return; } @@ -3937,47 +4426,40 @@ const SnippetOptionWidget = Widget.extend({ depName = depName.substr(1); } - const widget = this._requestUserValueWidgets(depName, true)[0]; - if (widget) { + const userValue = this._requestUserValueWidgets(depName, true)[0]; + if (userValue) { dependenciesData.push({ - widget: widget, + userValue: userValue, toBeActive: toBeActive, }); } }); const dependenciesOK = !dependenciesData.length || dependenciesData.some(depData => { - return (depData.widget.isActive() === depData.toBeActive); + return (depData.userValue.isActive() === depData.toBeActive); }); - widget.toggleVisibility(dependenciesOK); + userValue.toggleVisibility(dependenciesOK); }); return Promise.all(proms); }); - const showUI = await this._computeVisibility(); - this.el.classList.toggle('d-none', !showUI); + this.renderContext.showUI = await this._computeVisibility(); await Promise.all(proms); + for (const layout of this._layoutElements) { + const shouldShow = Object.values(layout.userValues) + .some(uv => uv.show); + layout.state.show = shouldShow; + } + + // TODO: @owl-options, layout should probably be hidden in template. // 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')) { - const $userValueWidget = $(el).find('> div > .o_we_user_value_widget'); - el.classList.toggle('d-none', $userValueWidget.length && !$userValueWidget.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); - } - return !this.displayOverlayOptions && showUI; - }, + return !this.displayOverlayOptions && this.renderContext.showUI; + } //-------------------------------------------------------------------------- // Private @@ -3997,7 +4479,7 @@ const SnippetOptionWidget = Widget.extend({ } } return messages.join(' '); - }, + } /** * @private * @param {UserValueWidget[]} widgets @@ -4005,14 +4487,14 @@ const SnippetOptionWidget = Widget.extend({ */ async _checkIfWidgetsUpdateNeedReload(widgets) { return false; - }, + } /** * @private * @returns {Promise|boolean} */ - _computeVisibility: async function () { + async _computeVisibility() { return true; - }, + } /** * Returns the string value that should be hold by the widget which is * related to the given method name. @@ -4024,7 +4506,7 @@ const SnippetOptionWidget = Widget.extend({ * @param {Object} params * @returns {Promise|string|undefined} */ - _computeWidgetState: async function (methodName, params) { + async _computeWidgetState(methodName, params) { switch (methodName) { case 'selectClass': { let maxNbClasses = 0; @@ -4154,7 +4636,6 @@ const SnippetOptionWidget = Widget.extend({ if (value === "currentColor") { return styles.color; } - return value; } case 'selectColorCombination': { @@ -4172,22 +4653,22 @@ const SnippetOptionWidget = Widget.extend({ return ''; } } - }, + } /** * @private * @param {string} widgetName * @param {Object} params * @returns {Promise|boolean} */ - _computeWidgetVisibility: async function (widgetName, params) { + async _computeWidgetVisibility(widgetName, params) { return true; - }, + } /** * @private * @param {HTMLElement} el * @returns {Object} */ - _extraInfoFromDescriptionElement: function (el) { + _extraInfoFromDescriptionElement(el) { return { title: el.getAttribute('string'), options: { @@ -4198,110 +4679,33 @@ const SnippetOptionWidget = Widget.extend({ childNodes: [...el.childNodes], }, }; - }, + } /** * @private * @param {*} * @returns {string} */ - _normalizeWidgetValue: function (value) { + _normalizeWidgetValue(value) { value = `${value}`.trim(); // Force to a trimmed string value = normalizeCSSColor(value); // If is a css color, normalize it return value; - }, - /** - * @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; - }, + } /** + * Allows options to share a context to their template / components. + * * @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 = registerUserValueWidget(widgetName, parentWidget || this, infos.title, infos.options, this.$target); - 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() && !widget.isDestroyed()) { - return this._renderXMLWidgets(widget.el, widget); - } - }); - }); - return Promise.all(proms); - }, + async _getRenderContext() { + return {}; + } /** * @private * @param {...string} widgetNames * @param {boolean} [allowParentOption=false] * @returns {UserValueWidget[]} */ - _requestUserValueWidgets: function (...args) { + _requestUserValueWidgets(...args) { const widgetNames = args; let allowParentOption = false; const lastArg = args[args.length - 1]; @@ -4313,9 +4717,8 @@ const SnippetOptionWidget = Widget.extend({ const widgets = []; for (const widgetName of widgetNames) { let widget = null; - this.trigger_up('user_value_widget_request', { + widget = this.callbacks.requestUserValue({ name: widgetName, - onSuccess: _widget => widget = _widget, allowParentOption: allowParentOption, }); if (widget) { @@ -4323,13 +4726,13 @@ const SnippetOptionWidget = Widget.extend({ } } return widgets; - }, + } /** * @private * @param {function>} [callback] * @returns {Promise} */ - _rerenderXML: async function (callback) { + async _rerenderXML(callback) { this._userValueWidgets.forEach(widget => widget.destroy()); this._userValueWidgets = []; this.$el.empty(); @@ -4343,7 +4746,7 @@ const SnippetOptionWidget = Widget.extend({ this.$el.append(uiFragment); return this.updateUI(); }); - }, + } /** * Activates the option associated to the given DOM element. * @@ -4352,10 +4755,10 @@ const SnippetOptionWidget = Widget.extend({ * - truthy if the option is enabled for preview or if leaving it (in * that second case, the value is 'reset') * - false if the option should be activated for good - * @param {UserValueWidget} widget - the widget which triggered the option change + * @param {UserValue} widget - the widget which triggered the option change * @returns {Promise} */ - _select: async function (previewMode, widget) { + async _select(previewMode, widget) { let $applyTo = null; if (previewMode === true) { @@ -4389,7 +4792,7 @@ const SnippetOptionWidget = Widget.extend({ // 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 * @@ -4400,7 +4803,7 @@ const SnippetOptionWidget = Widget.extend({ throw new Error('Attribute name missing'); } return this._selectValueHelper(value, params); - }, + } /** * Used to handle value of a select * @@ -4418,7 +4821,7 @@ const SnippetOptionWidget = Widget.extend({ this.$target.toggleClass(params.extraClass, params.defaultValue !== value); } return value; - }, + } /** * @private * @param {HTMLElement} collapseEl @@ -4427,7 +4830,7 @@ const SnippetOptionWidget = Widget.extend({ _toggleCollapseEl(collapseEl, show) { collapseEl.classList.toggle('active', show); collapseEl.querySelector('we-toggler.o_we_collapse_toggler').classList.toggle('active', show); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -4443,26 +4846,25 @@ const SnippetOptionWidget = Widget.extend({ 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; + async _onUserValueUpdate(params) { + const widget = params.widget; + const previewMode = params.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); + if (!params.previewMode && !params.isSimulatedEvent) { + const linkedWidgets = this._requestUserValueWidgets(...params.triggerWidgetsNames); + const widgets = [params.widget].concat(linkedWidgets); const warnMessage = await this._checkIfWidgetsUpdateNeedWarning(widgets); if (warnMessage) { @@ -4489,18 +4891,20 @@ const SnippetOptionWidget = Widget.extend({ this._actionQueues.get(widget).push(currentAction); // Ask a mutexed snippet update according to the widget value change - const shouldRecordUndo = (!previewMode && !ev.data.isSimulatedEvent); + const shouldRecordUndo = (!previewMode && !params.isSimulatedEvent); if (shouldRecordUndo) { this.options.wysiwyg.odooEditor.unbreakableStepUnactive(); } - const useLoaderOnOptionPanel = ev.target.el.dataset.loaderOnOptionPanel; - this.trigger_up('snippet_edition_request', {exec: async () => { + // TODO: @owl-options make it go through props + params. + // const useLoaderOnOptionPanel = ev.target.el.dataset.loaderOnOptionPanel; + const useLoaderOnOptionPanel = false; + this.options.snippetEditionRequest({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; - } + // 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]; @@ -4519,11 +4923,12 @@ const SnippetOptionWidget = Widget.extend({ } this._actionQueues.set(widget, actionQueue.filter(action => action !== currentAction)); - if (ev.data.prepare) { - ev.data.prepare(); + if (params.prepare) { + // Why must this be done before checking noPreview ? + params.prepare(); } - if (previewMode && (widget.$el.closest('[data-no-preview="true"]').length)) { + if (previewMode && (widget.getMethodsParams().noPreview)) { // TODO the flag should be fetched through widget params somehow return; } @@ -4544,15 +4949,14 @@ const SnippetOptionWidget = Widget.extend({ 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(), - }); + this.options.optionUpdate().then(resolve); // Set timeout needed so that the user event which triggered the // option can bubble first. })); }, optionsLoader: useLoaderOnOptionPanel}); - if (ev.data.isSimulatedEvent) { + // TODO: @owl-options is this still needed? + if (params.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 @@ -4560,13 +4964,14 @@ const SnippetOptionWidget = Widget.extend({ return; } + // TODO: @owl-options support triggerWidgets. // Check linked widgets: force their value and simulate a notification // It is possible that we don't have the widget, we continue because a // reload might be needed. For example, change template header without // being on a website.page (e.g: /shop). - const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames); + const linkedWidgets = this._requestUserValueWidgets(...params.triggerWidgetsNames); let i = 0; - const triggerWidgetsValues = ev.data.triggerWidgetsValues; + const triggerWidgetsValues = params.triggerWidgetsValues; for (const linkedWidget of linkedWidgets) { const widgetValue = triggerWidgetsValues[i]; if (widgetValue !== undefined) { @@ -4589,13 +4994,13 @@ const SnippetOptionWidget = Widget.extend({ } if (requiresReload) { - this.trigger_up('request_save', { + this.env.requestSave({ reloadEditor: true, optionSelector: this.data.selector, url: this.data.reload, }); } - }, + } /** * @private */ @@ -4603,13 +5008,102 @@ const SnippetOptionWidget = Widget.extend({ this.trigger_up('remove_snippet', { $snippet: this.$target, }); - }, -}); -const registry = {}; + } +} + + +class TestOption extends SnippetOption { + constructor() { + super(...arguments); + this.orm = serviceCached(this.env, "orm"); + this.listItems = ["Test1", "Test2", "Test3"]; + // M2O + this.pageId = ""; + this.newPage = {}; + // M2m + this.allGroupsByID = {}; + this.groupIDs = []; + + this.fetchGroups(); + } + async fetchGroups() { + const groups = await this.orm.searchRead( + "res.groups", + [], + ["id", "name", "display_name"] + ); + for (const group of groups) { + this.allGroupsByID[group.id] = group; + } + } + + renderListItems(previewMode, widgetValue, params) { + this.listItems = JSON.parse(widgetValue); + } + + setPageTemplate(previewMode, widgetValue, params) { + this.pageId = widgetValue; + } + createPage(previewMode, widgetValue, params) { + if (!widgetValue) { + return; + } + this.newPage = { + 'id': Math.floor(Math.random() * 8) + 1, + 'name': widgetValue, + 'display_name': widgetValue, + }; + this.pageId = this.newPage.id.toString(); + } + + setGroups(previewMode, widgetValue, params) { + this.groupIDs = JSON.parse(widgetValue).map(group => group.id); + } + createGroup(previewMode, widgetValue, params) { + if (!widgetValue) { + return; + } + const existing = Object.values(this.allGroupsByID).some(group => { + // A group is already existing only if it was already defined (i.e. + // id is a number) or if it appears in the current list of groups. + return group.name.toLowerCase() === widgetValue.toLowerCase() + && (typeof(group.id) === 'number' || this.groupIDs.includes(group.id)); + }); + if (existing) { + return; + } + const newGroupID = uniqueId("new_group_"); + this.allGroupsByID[newGroupID] = { + 'id': newGroupID, + 'name': widgetValue, + 'display_name': widgetValue, + }; + this.groupIDs.push(newGroupID); + } + + _computeWidgetState(methodName, params) { + if (methodName === "renderListItems") { + return JSON.stringify(this.listItems); + } else if (methodName === "setPageTemplate") { + return this.pageId; + } else if (methodName === "setGroups") { + return JSON.stringify(this.groupIDs.map(id => this.allGroupsByID[id])); + } + return super._computeWidgetState(...arguments); + } +} +// TODO: @owl-options remove this or include it in some tests? +//registerOption("test_option", { +// Class: TestOption, +// template: "web_editor.TestSnippetOption", +// selector: "section", +//}); + +const legacyRegistry = {}; //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -registry.sizing = SnippetOptionWidget.extend({ +legacyRegistry.sizing = SnippetOptionWidget.extend({ displayOverlayOptions: true, /** @@ -4980,7 +5474,7 @@ registry.sizing = SnippetOptionWidget.extend({ /** * Handles the edition of padding-top and padding-bottom. */ -registry['sizing_y'] = registry.sizing.extend({ +legacyRegistry['sizing_y'] = legacyRegistry.sizing.extend({ //-------------------------------------------------------------------------- // Private @@ -5013,7 +5507,7 @@ registry['sizing_y'] = registry.sizing.extend({ return this.grid; }, }); -registry['sizing_x'] = registry.sizing.extend({ +legacyRegistry['sizing_x'] = legacyRegistry.sizing.extend({ /** * @override */ @@ -5125,7 +5619,7 @@ registry['sizing_x'] = registry.sizing.extend({ /** * Handles the sizing in grid mode: edition of grid-{column|row}-{start|end}. */ -registry['sizing_grid'] = registry.sizing.extend({ +legacyRegistry['sizing_grid'] = legacyRegistry.sizing.extend({ /** * @override */ @@ -5227,7 +5721,7 @@ registry['sizing_grid'] = registry.sizing.extend({ /** * Controls box properties. */ -registry.Box = SnippetOptionWidget.extend({ +export class Box extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -5283,7 +5777,7 @@ registry.Box = SnippetOptionWidget.extend({ } } await this.selectStyle(previewMode, shadow, Object.assign({cssProperty: 'box-shadow'}, params)); - }, + } //-------------------------------------------------------------------------- // Private @@ -5300,8 +5794,8 @@ registry.Box = SnippetOptionWidget.extend({ } return this.$target.css('box-shadow').includes('inset') ? 'inset' : 'outset'; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -5309,8 +5803,8 @@ registry.Box = SnippetOptionWidget.extend({ if (widgetName === 'fake_inset_shadow_opt') { return false; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @private * @param {string} type @@ -5328,18 +5822,18 @@ registry.Box = SnippetOptionWidget.extend({ const shadow = `${$(el).css('box-shadow')}${type === 'inset' ? ' inset' : ''}`; el.remove(); return shadow; - }, -}); + } +} -registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { +export class LayoutColumn extends ColumnLayoutMixin(SnippetOption) { /** * @override */ cleanUI() { this._removeGridPreview(); - }, + } //-------------------------------------------------------------------------- // Options @@ -5350,7 +5844,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { * * @see this.selectClass for parameters */ - selectCount: async function (previewMode, widgetValue, params) { + async selectCount(previewMode, widgetValue, params) { // Make sure the "Custom" option is read-only. if (widgetValue === "custom") { return; @@ -5375,15 +5869,15 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { resetOuids($row[0]); $row.contents().unwrap().contents().unwrap(); restoreCursor(); - this.trigger_up('activate_snippet', {$snippet: this.$target}); + this.env.activateSnippet(this.$target); } else if (previousNbColumns === 0) { - this.trigger_up('activate_snippet', {$snippet: this.$('> .row').children().first()}); + this.env.activateSnippet(this.$('> .row').children().first()); } - this.trigger_up('option_update', { - optionName: 'StepsConnector', - name: 'change_columns', + this.callbacks.notifyOptions({ + optionNames: ["StepsConnector"], + name: "change_columns", }); - }, + } /** * Changes the layout (columns or grid). * @@ -5394,7 +5888,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { const rowEl = this.$target[0].querySelector('.row'); if (!rowEl || !rowEl.classList.contains('o_grid_mode')) { // Prevent toggling grid mode twice. gridUtils._toggleGridMode(this.$target[0]); - this.trigger_up('activate_snippet', {$snippet: this.$target}); + this.env.activateSnippet(this.$target); } } else { // Toggle normal mode only if grid mode was activated (as it's in @@ -5402,14 +5896,10 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { const rowEl = this.$target[0].querySelector('.row'); if (rowEl && rowEl.classList.contains('o_grid_mode')) { this._toggleNormalMode(rowEl); - this.trigger_up('activate_snippet', {$snippet: this.$target}); + this.env.activateSnippet(this.$target); } } - this.trigger_up('option_update', { - optionName: 'StepsConnector', - name: 'change_columns', - }); - }, + } /** * Adds an image, some text or a button in the grid. * @@ -5445,7 +5935,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { // Choose an image with the media dialog. let isImageSaved = false; await new Promise(resolve => { - this.call("dialog", "add", MediaDialog, { + this.env.services.dialog.add(MediaDialog, { onlyImages: true, save: imageEl => { isImageSaved = true; @@ -5518,13 +6008,13 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { if (!sameCoordinatesEl || !newColumnEl.contains(sameCoordinatesEl)) { newColumnEl.scrollIntoView({behavior: "smooth", block: "center"}); } - this.trigger_up('activate_snippet', {$snippet: $(newColumnEl)}); - }, + this.env.activateSnippet($(newColumnEl)); + } /** * @override */ async selectStyle(previewMode, widgetValue, params) { - await this._super(previewMode, widgetValue, params); + await super.selectStyle(previewMode, widgetValue, params); const rowEl = this.$target[0]; const isMobileView = weUtils.isMobileView(rowEl); @@ -5542,7 +6032,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { this.removeGridPreview = this._removeGridPreview.bind(this); rowEl.addEventListener("animationend", this.removeGridPreview); } - }, + } //-------------------------------------------------------------------------- // Private @@ -5551,7 +6041,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'selectCount') { const isMobile = this._isMobile(); const columnEls = this.$target[0].querySelector(":scope > .row")?.children; @@ -5564,8 +6054,8 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { return 'normal'; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -5590,8 +6080,8 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { this._areColsCustomized(this.$target[0].querySelector(":scope > .row").children, isMobile); } - return this._super(...arguments); - }, + return super._computeVisibility(...arguments); + } /** * If the number of columns requested is greater than the number of items, * adds new columns which are clones of the last one. If there are less @@ -5618,14 +6108,14 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { for (let i = 0; i < itemsDelta; i++) { const lastEl = rowEl.lastElementChild; newItems.push(new Promise(resolve => { - this.trigger_up("clone_snippet", {$snippet: $(lastEl), onSuccess: resolve}); + this.env.cloneSnippet($(lastEl)).then(resolve); })); } await Promise.all(newItems); } - this.trigger_up('cover_update'); - }, + this.callbacks.cover(); + } /** * Resizes the columns for the mobile or desktop view. * @@ -5655,7 +6145,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { const hasDesktopOffset = columnEl.className.match(/(^|\s+)offset-lg-[1-9][0-1]?(?!\S)/); columnEl.classList.toggle("offset-lg-0", hasMobileOffset && !hasDesktopOffset); } - }, + } /** * Toggles the normal mode. * @@ -5667,12 +6157,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { rowEl.classList.remove('o_grid_mode'); const columnEls = rowEl.children; // Removing the grid previews (if any). - await new Promise(resolve => { - this.trigger_up("clean_ui_request", { - targetEl: this.$target[0].closest("section"), - onSuccess: resolve, - }); - }); + await this.env.cleanUI(this.$target[0].closest("section")) for (const columnEl of columnEls) { // Reloading the images. @@ -5686,7 +6171,7 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { rowEl.style.removeProperty('--grid-item-padding-x'); rowEl.style.removeProperty('--grid-item-padding-y'); rowEl.style.removeProperty("gap"); - }, + } /** * Removes the grid preview that was added when changing the grid gaps. * @@ -5701,23 +6186,23 @@ registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutMixin, { } delete this.removeGridPreview; this.options.wysiwyg.odooEditor.observerActive("removeGridPreview"); - }, + } /** * @returns {boolean} */ _isMobile() { return weUtils.isMobileView(this.$target[0]); - }, -}); + } +} -registry.GridColumns = SnippetOptionWidget.extend({ +export class GridColumns extends SnippetOption { /** * @override */ cleanUI() { // Remove the padding highlights. this._removePaddingPreview(); - }, + } //-------------------------------------------------------------------------- // Options @@ -5727,7 +6212,7 @@ registry.GridColumns = SnippetOptionWidget.extend({ * @override */ async selectStyle(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectStyle(...arguments); if (["--grid-item-padding-y", "--grid-item-padding-x"].includes(params.cssProperty)) { // Reset the animation. this._removePaddingPreview(); @@ -5741,7 +6226,7 @@ registry.GridColumns = SnippetOptionWidget.extend({ this.removePaddingPreview = this._removePaddingPreview.bind(this); this.$target[0].addEventListener("animationend", this.removePaddingPreview); } - }, + } //-------------------------------------------------------------------------- // Private @@ -5754,8 +6239,8 @@ registry.GridColumns = SnippetOptionWidget.extend({ if (["grid_padding_y_opt", "grid_padding_x_opt"].includes(widgetName)) { return this.$target[0].parentElement.classList.contains("o_grid_mode"); } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Removes the padding highlights that were added when changing the grid * item padding. @@ -5768,10 +6253,10 @@ registry.GridColumns = SnippetOptionWidget.extend({ this.$target[0].classList.remove("o_we_padding_highlight"); delete this.removePaddingPreview; this.options.wysiwyg.odooEditor.observerActive("removePaddingPreview"); - }, -}); + } +} -registry.vAlignment = SnippetOptionWidget.extend({ +export class vAlignment extends SnippetOption { /** * @override */ @@ -5784,25 +6269,43 @@ registry.vAlignment = SnippetOptionWidget.extend({ return 'align-items-stretch'; } return value; - }, -}); + } +} + /** - * Allows snippets to be moved before the preceding element or after the following. + * Portal component that target the overlay */ -registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { - displayOverlayOptions: true, +export class WeOverlay extends Component { + static template = "__portal__"; + + setup() { + const node = this.__owl__; + onMounted(() => { + const targetEl = this.env.snippetOption.$overlay[0].querySelector(this.props.target); + const portal = node.bdom; + portal.content.moveBeforeDOMNode(targetEl.firstChild, targetEl); + }); + onWillUnmount(() => { + const portal = node.bdom; + portal.remove(); + }); + } +} +registry.category("snippet_widgets").add("WeOverlay", WeOverlay); + + +/** + * Allows snippets to be moved before the preceding element or after the following. + */ +export class SnippetMove extends ColumnLayoutMixin(SnippetOption) { + static displayOverlayOptions = true; + /** * @override */ - start: function () { - var $buttons = this.$el.find('we-button'); - var $overlayArea = this.$overlay.find('.o_overlay_move_options'); - // Putting the arrows side by side. - $overlayArea.prepend($buttons[1]); - $overlayArea.prepend($buttons[0]); - + willStart() { // Needed for compatibility (with already dropped snippets). // If the target is a column, check if all the columns are either mobile // ordered or not. If they are not consistent, then we remove the mobile @@ -5816,13 +6319,13 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { } } - return this._super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override */ onClone(options) { - this._super.apply(this, arguments); + super.onClone(arguments); const mobileOrder = this.$target[0].style.order; // If the order has been adapted on mobile, it must be different // for each clone. @@ -5839,20 +6342,20 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { } }); } - }, + } /** * @override */ onMove() { - this._super.apply(this, arguments); + super.onMove(arguments); // Remove all the mobile order classes after a drag and drop. this._removeMobileOrders(this.$target[0].parentElement.children); - }, + } /** * @override */ onRemove() { - this._super.apply(this, arguments); + super.onRemove(arguments); const targetMobileOrder = this.$target[0].style.order; // If the order has been adapted on mobile, the gap created by the // removed snippet must be filled in. @@ -5860,7 +6363,7 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { const targetOrder = parseInt(targetMobileOrder); this._fillRemovedItemGap(this.$target[0].parentElement, targetOrder); } - }, + } //-------------------------------------------------------------------------- // Options @@ -5871,7 +6374,7 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { * * @see this.selectClass for parameters */ - moveSnippet: function (previewMode, widgetValue, params) { + moveSnippet(previewMode, widgetValue, params) { const isMobile = this._isMobile(); const isNavItem = this.$target[0].classList.contains('nav-item'); const $tabPane = isNavItem ? $(this.$target.find('.nav-link')[0].hash) : null; @@ -5934,14 +6437,20 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { }); } } + // TODO: @owl-options were parameters even used in there ? + this.options.optionUpdate("StepsConnector", "move_snippet"); + /* this.trigger_up('option_update', { optionName: 'StepsConnector', name: 'move_snippet', }); + */ // Update the "Invisible Elements" panel as the order of invisible // snippets could have changed on the page. - this.trigger_up("update_invisible_dom"); - }, + // TODO: @owl-options ? + // this.options._updateInvisibleDom(); + // this.trigger_up("update_invisible_dom"); + } //-------------------------------------------------------------------------- // Private @@ -5993,8 +6502,8 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { } return !!siblingEl; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * Swaps the mobile orders. * @@ -6016,24 +6525,40 @@ registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutMixin, { comparedEl.style.order = targetMobileOrder; break; } - }, + } /** * @returns {Boolean} */ _isMobile() { return false; - }, + } +} + +registerOption("SnippetMove (Vertical)", { + Class: SnippetMove, + template: "web_editor.snippet_move_option_vertical", + selector: "section, .accordion > .card, .s_showcase .row:not(.s_col_no_resize) > div, .s_hr", + noScroll: ".accordion > .card", +}); + +registerOption("SnippetMove (Horizontal)", { + Class: SnippetMove, + template: "web_editor.snippet_move_option_horizontal", + selector: ".row:not(.s_col_no_resize) > div, .nav-item", + exclude: ".s_showcase .row > div", + name: "move_horizontally_opt", }); + /** * Allows for media to be replaced. */ -registry.ReplaceMedia = SnippetOptionWidget.extend({ - init: function () { - this._super(...arguments); +export class ReplaceMedia extends SnippetOption { + constructor() { + super(...arguments); this._activateLinkTool = this._activateLinkTool.bind(this); this._deactivateLinkTool = this._deactivateLinkTool.bind(this); - }, + } /** * @override @@ -6044,14 +6569,14 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ // When we start editing an image, rerender the UI to ensure the // we-select that suggests the anchors is in a consistent state. this.rerender = true; - }, + } /** * @override */ onBlur() { this.options.wysiwyg.odooEditor.removeEventListener('activate_image_link_tool', this._activateLinkTool); this.options.wysiwyg.odooEditor.removeEventListener('deactivate_image_link_tool', this._deactivateLinkTool); - }, + } //-------------------------------------------------------------------------- // Options @@ -6065,7 +6590,7 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ async replaceMedia() { // open mediaDialog and replace the media. await this.options.wysiwyg.openMediaDialog({ node:this.$target[0] }); - }, + } /** * Makes the image a clickable link by wrapping it in an . * This function is also called for the opposite operation. @@ -6090,7 +6615,7 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ fragment.append(...parentEl.childNodes); parentEl.replaceWith(fragment); } - }, + } /** * Changes the image link so that the URL is opened on another tab or not * when it is clicked. @@ -6104,7 +6629,7 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ } else { linkEl.removeAttribute('target'); } - }, + } /** * Records the target url of the hyperlink. * @@ -6128,18 +6653,18 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ linkEl.setAttribute('href', url); this.rerender = true; this.$target.trigger('href_changed'); - }, + } /** * @override */ async updateUI() { if (this.rerender) { this.rerender = false; - await this._rerenderXML(); + // await this._rerenderXML(); return; } - return this._super.apply(this, arguments); - }, + return super.updateUI(this, arguments); + } //-------------------------------------------------------------------------- // Private @@ -6154,7 +6679,7 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ } else { this._requestUserValueWidgets('media_link_opt')[0].enable(); } - }, + } /** * @private */ @@ -6163,7 +6688,7 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ if (parentEl.tagName === 'A') { this._requestUserValueWidgets('media_link_opt')[0].enable(); } - }, + } /** * @override */ @@ -6183,8 +6708,8 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ return target && target === '_blank' ? 'true' : ''; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -6195,38 +6720,42 @@ registry.ReplaceMedia = SnippetOptionWidget.extend({ } return !this.$target[0].classList.contains('media_iframe_video'); } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } +} + +registerOption("ReplaceMedia", { + Class: ReplaceMedia, + template: "web_editor.replace_media_option", + selector: "img, .media_iframe_video, span.fa, i.fa", + exclude: "[data-oe-xpath], a[href^='/website/social/'] > i.fa, a[class*='s_share_'] > i.fa", }); /* * Abstract option to be extended by the ImageTools and BackgroundOptimize * options that handles all the common parts. */ -const ImageHandlerOption = SnippetOptionWidget.extend({ +export class ImageHandlerOption extends SnippetOption { /** * @override */ async willStart() { - const _super = this._super.bind(this); await this._initializeImage(); - return _super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override + * */ async start() { + // TODO: @owl-options This is currently not called await this._super(...arguments); - const weightEl = document.createElement('span'); - weightEl.classList.add('o_we_image_weight', 'o_we_tag', 'd-none'); - weightEl.title = _t("Size"); - this.$weight = $(weightEl); // Perform the loading of the image info synchronously in order to // avoid an intermediate rendering of the Blocks tab during the // loadImageInfo RPC that obtains the file size. // This does not update the target. await this._applyOptions(false); - }, + } //-------------------------------------------------------------------------- // Public @@ -6236,18 +6765,14 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ * @override */ async updateUI() { - await this._super(...arguments); + await super.updateUI(...arguments); if (this._filesize === undefined) { - this.$weight.addClass('d-none'); await this._applyOptions(false); } - if (this._filesize !== undefined) { - this.$weight.text(`${this._filesize.toFixed(1)} kb`); - this.$weight.removeClass('d-none'); - this._relocateWeightEl(); - } - }, + + this._relocateWeightEl(); + } //-------------------------------------------------------------------------- // Options @@ -6269,7 +6794,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ image.dataset.mimetype = values[1]; } return this._applyOptions(); - }, + } /** * @see this.selectClass for parameters */ @@ -6279,7 +6804,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ } this._getImg().dataset.quality = widgetValue; return this._applyOptions(); - }, + } /** * @see this.selectClass for parameters */ @@ -6291,7 +6816,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ delete dataset.glFilter; } return this._applyOptions(); - }, + } /** * @see this.selectClass for parameters */ @@ -6305,7 +6830,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ const newOptions = Object.assign(JSON.parse(filterOptions || "{}"), {[filterProperty]: widgetValue}); img.dataset.filterOptions = JSON.stringify(newOptions); return this._applyOptions(); - }, + } //-------------------------------------------------------------------------- // Private @@ -6317,13 +6842,12 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ _computeVisibility() { const src = this._getImg().getAttribute('src'); return src && src !== '/'; - }, + } /** * @override */ async _computeWidgetState(methodName, params) { const img = this._getImg(); - const _super = this._super.bind(this); // Make sure image is loaded because we need its naturalWidth await new Promise((resolve, reject) => { @@ -6351,32 +6875,26 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ return options[filterProperty] || defaultValue; } } - return _super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @abstract */ - _relocateWeightEl() {}, + _relocateWeightEl() {} /** * @override */ - async _renderCustomXML(uiFragment) { + async _getRenderContext() { + const context = await super._getRenderContext(); const img = this._getImg(); if (!this.originalSrc || !this._isImageSupportedForProcessing(img)) { - return; + return context; } - const $select = $(uiFragment).find('we-select[data-name=format_select_opt]'); - (await this._computeAvailableFormats()).forEach(([value, [label, targetFormat]]) => { - $select.append(`${label} ${targetFormat.split('/')[1]}`); - }); + context.availableFormats = await this._computeAvailableFormats(); + context.noQuality = !['image/jpeg', 'image/webp'].includes(this._getImageMimetype(img)); - if (!['image/jpeg', 'image/webp'].includes(this._getImageMimetype(img))) { - const optQuality = uiFragment.querySelector('we-range[data-set-quality]'); - if (optQuality) { - optQuality.remove(); - } - } - }, + return context; + } /** * Returns a list of valid formats for a given image or an empty list if * there is no mimetypeBeforeConversion data attribute on the image. @@ -6411,7 +6929,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ return Object.entries(widths) .filter(([width]) => width <= maxWidth) .sort(([v1], [v2]) => v1 - v2); - }, + } /** * Applies all selected options on the original image. * @@ -6448,7 +6966,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ return loadedImg; } return img; - }, + } /** * Loads the image's attachment info. * @@ -6465,7 +6983,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ this.originalId = img.dataset.originalId; this.originalSrc = img.dataset.originalSrc; this.mimetypeBeforeConversion = img.dataset.mimetypeBeforeConversion; - }, + } /** * Sets the image's width to its suggested size. * @@ -6473,7 +6991,8 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ */ async _autoOptimizeImage() { await this._loadImageInfo(); - await this._rerenderXML(); + const newContext = await this._getRenderContext(); + Object.assign(this.renderContext, newContext); const img = this._getImg(); if (!['image/gif', 'image/svg+xml'].includes(img.dataset.mimetype)) { // Convert to recommended format and width. @@ -6485,7 +7004,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ } await this._applyOptions(); await this.updateUI(); - }, + } /** * Returns the image that is currently being modified. * @@ -6493,7 +7012,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ * @abstract * @returns {HTMLImageElement} the image to use for modifications */ - _getImg() {}, + _getImg() {} /** * Computes the image's maximum display width. * @@ -6501,7 +7020,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ * @abstract * @returns {Int} the maximum width at which the image can be displayed */ - _computeMaxDisplayWidth() {}, + _computeMaxDisplayWidth() {} /** * Use the processed image when it's needed in the DOM. * @@ -6509,7 +7028,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ * @abstract * @param {HTMLImageElement} img */ - _applyImage(img) {}, + _applyImage(img) {} /** * @private * @param {HTMLImageElement} img @@ -6517,13 +7036,13 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ */ _getImageMimetype(img) { return img.dataset.mimetype; - }, + } /** * @private */ async _initializeImage() { return this._loadImageInfo(); - }, + } /** * @private * @param {HTMLImageElement} img @@ -6532,7 +7051,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ */ _isImageSupportedForProcessing(img, strict = false) { return isImageSupportedForProcessing(this._getImageMimetype(img), strict); - }, + } /** * @override */ @@ -6545,7 +7064,7 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ return this._isImageSupportedForProcessing(img, true); } return isImageSupportedForStyle(this._getImg()); - }, + } /** * Indicates if an option should be applied only on supported mimetypes. * @@ -6558,8 +7077,8 @@ const ImageHandlerOption = SnippetOptionWidget.extend({ || 'customFilter' in params.optionsPossibleValues || params.optionsPossibleValues.setQuality || widgetName === 'format_select_opt'; - }, -}); + } +} /** * @param {Element} containerEl @@ -6590,7 +7109,8 @@ const _addAnimatedShapeLabel = function addAnimatedShapeLabel(containerEl, label /** * Controls image width and quality. */ -registry.ImageTools = ImageHandlerOption.extend({ + +legacyRegistry.ImageTools = { MAX_SUGGESTED_WIDTH: 1920, /** @@ -7916,26 +8436,26 @@ registry.ImageTools = ImageHandlerOption.extend({ async _onImageCropped(ev) { await this._rerenderXML(); }, -}); +}; /** * Controls background image width and quality. */ -registry.BackgroundOptimize = ImageHandlerOption.extend({ +export class BackgroundOptimize extends ImageHandlerOption { /** * @override */ - start() { + async willStart() { this.$target.on('background_changed.BackgroundOptimize', this._onBackgroundChanged.bind(this)); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override */ - destroy() { + cleanForSave() { this.$target.off('.BackgroundOptimize'); - return this._super(...arguments); - }, + return super.cleanForSave(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -7946,13 +8466,13 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ */ _getImg() { return this.img; - }, + } /** * @override */ _computeMaxDisplayWidth() { return 1920; - }, + } /** * Initializes this.img to an image with the background image url as src. * @@ -7977,18 +8497,18 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ // modified) this.img.src = src.startsWith("/") ? src : ""; } - return await this._super(...arguments); - }, + return await super._loadImageInfo(...arguments); + } /** * @override */ _relocateWeightEl() { - this.trigger_up('option_update', { - optionNames: ['BackgroundImage'], - name: 'add_size_indicator', - data: this.$weight, - }); - }, + this.callbacks.notifyOptions({ + optionNames: ['BackgroundImage'], + name: 'add_size_indicator', + data: this._filesize, + }); + } /** * @override */ @@ -8012,7 +8532,7 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ this.$target[0].dataset[key] = value; }); this.$target[0].dataset.bgSrc = img.getAttribute("src"); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -8026,14 +8546,14 @@ registry.BackgroundOptimize = ImageHandlerOption.extend({ async _onBackgroundChanged(ev, previewMode) { ev.stopPropagation(); if (!previewMode) { - this.trigger_up('snippet_edition_request', {exec: async () => { + this.env.snippetEditionRequest(async () => { await this._autoOptimizeImage(); - }}); + }); } - }, -}); + } +} -registry.BackgroundToggler = SnippetOptionWidget.extend({ +export class BackgroundToggler extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -8049,13 +8569,13 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ this.$target.find('> .o_we_bg_filter').remove(); // TODO: use setWidgetValue instead of calling background directly when possible const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); - const bgImageOpt = bgImageWidget.getParent(); + const bgImageOpt = bgImageWidget.option; 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(); + this._requestUserValueWidgets('bg_image_opt')[0].enable(); } - }, + } /** * Toggles background shape on or off. * @@ -8063,11 +8583,11 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ */ toggleBgShape(previewMode, widgetValue, params) { const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); - const shapeOption = shapeWidget.getParent(); + const shapeOption = shapeWidget.option; // TODO: open select after shape was selected? // TODO: use setWidgetValue instead of calling shapeOption method directly when possible return shapeOption._toggleShape(); - }, + } /** * Sets a color filter. * @@ -8102,7 +8622,7 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ const obj = createPropertyProxy(this, '$target', $(filterEl)); params.cssProperty = 'background-color'; return this.selectStyle.call(obj, previewMode, widgetValue, params); - }, + } //-------------------------------------------------------------------------- // Private @@ -8115,12 +8635,12 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ switch (methodName) { case 'toggleBgImage': { const [bgImageWidget] = this._requestUserValueWidgets('bg_image_opt'); - const bgImageOpt = bgImageWidget.getParent(); + const bgImageOpt = bgImageWidget.option; return !!bgImageOpt._computeWidgetState('background', bgImageWidget.getMethodsParams('background')); } case 'toggleBgShape': { const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); - const shapeOption = shapeWidget.getParent(); + const shapeOption = shapeWidget.option; return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape')); } case 'selectFilterColor': { @@ -8133,27 +8653,27 @@ registry.BackgroundToggler = SnippetOptionWidget.extend({ return this._computeWidgetState.call(obj, 'selectStyle', params); } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @private */ _getLastPreFilterLayerElement() { return null; - }, -}); + } +} /** * Handles the edition of snippet's background image. */ -registry.BackgroundImage = SnippetOptionWidget.extend({ +export class BackgroundImage extends SnippetOption { /** * @override */ - start: function () { + willStart() { this.__customImageSrc = getBgImageURL(this.$target[0]); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } //-------------------------------------------------------------------------- // Options @@ -8164,7 +8684,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ * * @see this.selectClass for parameters */ - background: async function (previewMode, widgetValue, params) { + async background(previewMode, widgetValue, params) { if (previewMode === true) { this.__customImageSrc = getBgImageURL(this.$target[0]); } else if (previewMode === 'reset') { @@ -8179,7 +8699,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ removeOnImageChangeAttrs.forEach(attr => delete this.$target[0].dataset[attr]); this.$target.trigger('background_changed', [previewMode]); } - }, + } /** * Changes the main color of dynamic SVGs. * @@ -8203,7 +8723,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ if (!previewMode) { this.previousSrc = src; } - }, + } //-------------------------------------------------------------------------- // Public @@ -8214,15 +8734,15 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ */ notify(name, data) { if (name === 'add_size_indicator') { - this._requestUserValueWidgets('bg_image_opt')[0].$el.after(data); + this.renderContext.filesize = data && `${data.toFixed(1)} kb` || undefined; } else { - this._super(...arguments); + super.notify(...arguments); } - }, + } /** * @override */ - setTarget: function () { + setTarget() { // When we change the target of this option we need to transfer the // background-image and the dataset information relative to this image // from the old target to the new one. @@ -8240,7 +8760,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ // target as its image source will be deleted. this.$target[0].classList.remove("o_modified_image_to_save"); this._setBackground(''); - this._super(...arguments); + super.setTarget(...arguments); if (oldBgURL) { this._setBackground(oldBgURL); filteredOldDataset.forEach(([key, value]) => { @@ -8251,7 +8771,7 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ // TODO should be automatic for all options as equal to the start method this.__customImageSrc = getBgImageURL(this.$target[0]); - }, + } //-------------------------------------------------------------------------- // Private @@ -8260,15 +8780,15 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'background': return getBgImageURL(this.$target[0]); case 'dynamicColor': return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get(params.colorName); - } - return this._super(...arguments); - }, + } + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -8280,8 +8800,8 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ 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); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @private * @param {string} backgroundURL @@ -8306,23 +8826,28 @@ registry.BackgroundImage = SnippetOptionWidget.extend({ this.selectStyle(false, combined, { cssProperty: 'background-image', }); - }, -}); + } +} /** * Handles background shapes. */ -registry.BackgroundShape = SnippetOptionWidget.extend({ +export class BackgroundShape extends SnippetOption { + constructor() { + super(...arguments); + this.debugRow = true; + } /** * @override */ - updateUI({assetsChanged} = {}) { + async updateUI({assetsChanged} = {}) { if (this.rerender || assetsChanged) { this.rerender = false; - return this._rerenderXML(); + const newContext = await this._getRenderContext(); + Object.assign(this.renderContext, newContext); } - return this._super.apply(this, arguments); - }, + return super.updateUI(this, arguments); + } /** * @override */ @@ -8334,7 +8859,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ return {flip: this._getShapeData().flip}; }); } - }, + } //-------------------------------------------------------------------------- // Options @@ -8355,7 +8880,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ shapeAnimationSpeed: this._getShapeData().shapeAnimationSpeed, }; }); - }, + } /** * Sets the current background shape's colors. * @@ -8369,7 +8894,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ const newColors = Object.assign(previousColors, {[colorName]: newColor}); return {colors: newColors}; }); - }, + } /** * Flips the shape on its x axis. * @@ -8377,7 +8902,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ */ flipX(previewMode, widgetValue, params) { this._flipShape(previewMode, 'x'); - }, + } /** * Flips the shape on its y axis. * @@ -8385,7 +8910,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ */ flipY(previewMode, widgetValue, params) { this._flipShape(previewMode, 'y'); - }, + } /** * Shows/Hides the shape on mobile. * @@ -8395,7 +8920,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ this._handlePreviewState(previewMode, () => { return {showOnMobile: !this._getShapeData().showOnMobile}; }); - }, + } /** * Sets the speed of the animation of a background shape. * @@ -8405,7 +8930,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ this._handlePreviewState(previewMode, () => { return { shapeAnimationSpeed: widgetValue }; }); - }, + } //-------------------------------------------------------------------------- // Private @@ -8442,8 +8967,8 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ return this._getShapeData().shapeAnimationSpeed; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -8452,16 +8977,14 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ const bgShapeWidget = this._requestUserValueWidgets("bg_shape_opt")[0]; return bgShapeWidget.getMethodsParams().animated === "true"; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @override */ - _renderCustomXML(uiFragment) { - Object.keys(this._getDefaultColors()).map(colorName => { - uiFragment.querySelector('[data-name="colors"]') - .prepend($(``)[0]); - }); + async _getRenderContext() { + const context = await super._getRenderContext(); + const colorPickers = Object.keys(this._getDefaultColors()); // Inventory shape URLs per class. const style = window.getComputedStyle(this.$target[0]); @@ -8481,30 +9004,11 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } } } + context._shapeBackgroundImagePerClass = this._shapeBackgroundImagePerClass; } - - 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); - - if (btn.dataset.animated) { - _addAnimatedShapeLabel(btnContent); - } - - const {shape} = btn.dataset; - const shapeEl = btnContent.querySelector('.o_we_shape'); - const shapeClassName = `o_${shape.replace(/\//g, '_')}`; - shapeEl.classList.add(shapeClassName); - // Match current palette. - const shapeBackgroundImage = this._shapeBackgroundImagePerClass[`.o_we_shape.${shapeClassName}`]; - shapeEl.style.setProperty("background-image", shapeBackgroundImage); - btn.append(btnContent); - }); - return uiFragment; - }, + context.colorPickers = colorPickers; + return context; + } /** * Flips the shape on its x/y axis. * @@ -8521,7 +9025,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } return {flip: [...flip]}; }); - }, + } /** * Inserts or removes the given container at the right position in the * document. @@ -8544,7 +9048,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } } return newContainer; - }, + } /** * Creates and inserts a container for the shape with the right classes. * @@ -8555,7 +9059,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ this.$target[0].style.position = 'relative'; shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, '_')}`; return shapeContainer; - }, + } /** * Handles everything related to saving state before preview and restoring * it after a preview or locking in the changes when not in preview. @@ -8632,14 +9136,14 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ this.prevShapeContainer = shapeContainer.cloneNode(true); this.prevShape = target.dataset.oeShapeData; } - }, + } /** * @private * @param {HTMLElement} shapeEl */ _removeShapeEl(shapeEl) { shapeEl.remove(); - }, + } /** * Overwrites shape properties with the specified data. * @@ -8660,7 +9164,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } else { this.$target[0].dataset.oeShapeData = JSON.stringify(shapeData); } - }, + } /** * @private */ @@ -8670,7 +9174,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ return $filterEl[0]; } return null; - }, + } /** * Returns the src of the shape corresponding to the current parameters. * @@ -8693,7 +9197,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ searchParams.push(`shapeAnimationSpeed=${encodeURIComponent(shapeAnimationSpeed)}`); } return `/web_editor/shape/${encodeURIComponent(shape)}.svg?${searchParams.join('&')}`; - }, + } /** * Retrieves current shape data from the target's dataset. * @@ -8711,7 +9215,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ }; 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. * @@ -8734,7 +9238,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } const url = new URL(shapeSrc, window.location.origin); return Object.fromEntries(url.searchParams.entries()); - }, + } /** * Returns the default colors for the a shape in the selector. * @@ -8742,12 +9246,11 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ * @param {String} shapeId identifier of the shape */ _getShapeDefaultColors(shapeId) { - const $shapeContainer = this.$el.find(".o_we_bg_shape_menu we-button[data-shape='" + shapeId + "'] div.o_we_shape"); - const shapeContainer = $shapeContainer[0]; - const shapeSrc = shapeContainer && getBgImageURL(shapeContainer); + const shapeClassName = `o_we_shape.${shapeId.replace(new RegExp('/', 'g'), '_')}`; + const shapeSrc = getBgImageURL(this.renderContext._shapeBackgroundImagePerClass[shapeClassName]); const url = new URL(shapeSrc, window.location.origin); return Object.fromEntries(url.searchParams.entries()); - }, + } /** * Returns the implicit colors for the currently selected shape. * @@ -8772,7 +9275,7 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ const defaultKeys = Object.keys(defaultColors); colors = Object.assign(defaultColors, colors); return pick(colors, ...defaultKeys); - }, + } /** * Toggles whether there is a shape or not, to be called from bg toggler. * @@ -8785,7 +9288,8 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ const target = this.$target[0]; const previousSibling = target.previousElementSibling; const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); - const possibleShapes = shapeWidget.getMethodsParams('shape').possibleValues; + const params = shapeWidget.getMethodsParams("shape"); + const possibleShapes = params.possibleValues; let shapeToSelect; if (previousSibling) { const previousShape = this._getShapeData(previousSibling).shape; @@ -8802,9 +9306,9 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } // Only show on mobile by default if toggled from mobile view const showOnMobile = weUtils.isMobileView(this.$target[0]); - this.trigger_up('snippet_edition_request', {exec: () => { + this.options.snippetEditionRequest({exec: () => { // options for shape will only be available after _toggleShape() returned - this._requestUserValueWidgets('bg_shape_opt')[0].enable(); + this._requestUserValueWidgets('bg_shape_opt')[0].open(); }}); this._createShapeContainer(shapeToSelect); return this._handlePreviewState(false, () => ( @@ -8815,55 +9319,68 @@ registry.BackgroundShape = SnippetOptionWidget.extend({ } )); } - }, -}); + } +} + +export class WeShapeBtn extends Component { + static template = "web_editor.WeShapeBtn"; + static components = { WeButton }; + static props = { + shape: String, + selectLabel: String, + animated: { type: Boolean, optional: true }, + }; + setup() { + this.renderContext = useState(this.env.renderContext); + } +} + +registry.category("snippet_widgets").add("WeShapeBtn", WeShapeBtn); + +export class BackgroundShapePosition extends SnippetOptionComponent { + static template = "web_editor.BackgroundShape"; +} /** * Handles the edition of snippets' background image position. */ -registry.BackgroundPosition = SnippetOptionWidget.extend({ +class BackgroundPosition extends SnippetOption { /** * @override */ - start: function () { - this._super.apply(this, arguments); - + willStart() { 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()); - }, + return super.willStart(); + } /** * @override */ - destroy: function () { + cleanUI() { 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) { + backgroundType(previewMode, widgetValue, params) { this.$target.toggleClass('o_bg_img_opt_repeat', widgetValue === 'repeat-pattern'); this.$target.css('background-position', ''); this.$target.css('background-size', widgetValue !== 'repeat-pattern' ? '' : '100px'); - }, + } /** * Saves current background position and enables overlay. * * @see this.selectClass for params */ - backgroundPositionOverlay: async function (previewMode, widgetValue, params) { + async backgroundPositionOverlay(previewMode, widgetValue, params) { // Updates the internal image await new Promise(resolve => { this.img = document.createElement('img'); @@ -8896,11 +9413,11 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ await scrollTo(this.$target[0], { behavior: "smooth", offset: 50 }); } this._toggleBgOverlay(true); - }, + } /** * @override */ - selectStyle: function (previewMode, widgetValue, params) { + selectStyle(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 @@ -8908,7 +9425,7 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ return; } this._super(...arguments); - }, + } //-------------------------------------------------------------------------- // Private @@ -8917,25 +9434,25 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ /** * @override */ - _computeVisibility: function () { - return this._super(...arguments) && !!getBgImageURL(this.$target[0]); - }, + _computeVisibility() { + return super._computeVisibility && !!getBgImageURL(this.$target[0]); + } /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'backgroundType') { return this.$target.css('background-repeat') === 'repeat' ? 'repeat-pattern' : 'cover'; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Initializes the overlay, binds events to the buttons, inserts it in * the DOM. * * @private */ - _initOverlay: function () { + _initOverlay() { this.$backgroundOverlay = $(renderToElement('web_editor.background_position_overlay')); this.$overlayContent = this.$backgroundOverlay.find('.o_we_overlay_content'); this.$overlayBackground = this.$overlayContent.find('.o_overlay_background'); @@ -8949,14 +9466,14 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ }); 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 () { + _dimensionOverlay() { if (!this.$backgroundOverlay.is('.oe_active')) { return; } @@ -8978,22 +9495,22 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ const topPos = Math.max(0, $(window).scrollTop() - this.$target.offset().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) { + _toggleBgOverlay(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}); + this.env.unblockPreviewOverlays(); + this.env.activateSnippet(this.$target); $(document).off('click.bgposition'); if (this.$bgDragger) { @@ -9002,12 +9519,9 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ return; } - this.trigger_up('hide_overlay'); - this.trigger_up('activate_snippet', { - $snippet: this.$target, - previewMode: true, - }); - this.trigger_up('block_preview_overlays'); + this.env.hideOverlay(); + this.env.activateSnippet(this.$target, true); + this.env.blockPreviewOverlays(); // Create empty clone of $target with same display size, make it draggable and give it a tooltip. this.$bgDragger = this.$target.clone().empty(); @@ -9035,14 +9549,14 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ // 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 () { + _getBackgroundDelta() { const bgSize = this.$target.css('background-size'); if (bgSize !== 'cover') { let [width, height] = bgSize.split(' '); @@ -9069,7 +9583,7 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ x: this.$target.outerWidth() - Math.round(renderRatio * this.img.naturalWidth), y: this.$target.outerHeight() - Math.round(renderRatio * this.img.naturalHeight), }; - }, + } //-------------------------------------------------------------------------- // Handlers @@ -9080,7 +9594,7 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ * * @private */ - _onDragBackgroundStart: function (ev) { + _onDragBackgroundStart(ev) { ev.preventDefault(); this.$bgDragger.addClass('o_we_grabbing'); const $document = $(this.$target[0].ownerDocument); @@ -9089,13 +9603,13 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ 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) { + _onDragBackgroundMove(ev) { ev.preventDefault(); const delta = this._getBackgroundDelta(); @@ -9119,18 +9633,18 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ 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) { + _onDocumentClicked(ev) { if (!$(ev.target).closest('.o_we_background_position_overlay').length) { this._toggleBgOverlay(false); } - }, -}); + } +} /** * Marks color levels of any element that may get or has a color classes. This @@ -9138,20 +9652,20 @@ registry.BackgroundPosition = SnippetOptionWidget.extend({ * 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({ +export class ColoredLevelBackground extends BackgroundToggler { /** * @override */ - start: function () { + start() { this._markColorLevel(); return this._super(...arguments); - }, + } /** * @override */ - onBuilt: function () { + onBuilt() { this._markColorLevel(); - }, + } //-------------------------------------------------------------------------- // Private @@ -9165,20 +9679,20 @@ registry.ColoredLevelBackground = registry.BackgroundToggler.extend({ * * @private */ - _markColorLevel: function () { + _markColorLevel() { this.options.wysiwyg.odooEditor.observerUnactive('_markColorLevel'); this.$target.addClass('o_colored_level'); this.options.wysiwyg.odooEditor.observerActive('_markColorLevel'); - }, -}); + } +} -registry.ContainerWidth = SnippetOptionWidget.extend({ +class ContainerWidth extends SnippetOption { /** * @override */ - cleanForSave: function () { + cleanForSave() { this.$target.removeClass('o_container_preview'); - }, + } //-------------------------------------------------------------------------- // Options @@ -9187,25 +9701,33 @@ registry.ContainerWidth = SnippetOptionWidget.extend({ /** * @override */ - selectClass: async function (previewMode, widgetValue, params) { - await this._super(...arguments); + async selectClass(previewMode, widgetValue, params) { + await super.selectClass(...arguments); if (previewMode === 'reset') { this.$target.removeClass('o_container_preview'); } else if (previewMode) { this.$target.addClass('o_container_preview'); } - this.trigger_up('option_update', { + this.callbacks.notifyOptions({ optionName: 'StepsConnector', name: 'change_container_width', }); - }, + } +} + +registerOption("container_width", { + Class: ContainerWidth, + template: "web_editor.container_width", + selector: "section, .s_carousel .carousel-item, s_quotes_carousel .carousel-item", + exclude: "[data-snippet] :not(.oe_structure) > [data-snippet]", + target: "> .container, > .container-fluid, > .o_container_small", }); /** * 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({ +legacyRegistry.many2one = SnippetOptionWidget.extend({ init() { this._super(...arguments); this.orm = this.bindService("orm"); @@ -9331,7 +9853,7 @@ registry.many2one = SnippetOptionWidget.extend({ /** * Allows to display a warning message on outdated snippets. */ -registry.VersionControl = SnippetOptionWidget.extend({ +class VersionControl extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -9343,7 +9865,7 @@ registry.VersionControl = SnippetOptionWidget.extend({ async replaceSnippet() { // Getting the new block version. let newBlockEl; - this.trigger_up("find_snippet_template", { + this.env.findSnippetTemplate({ snippet: this.$target[0], callback: (snippet) => { newBlockEl = snippet.baseBody.cloneNode(true); @@ -9363,27 +9885,27 @@ registry.VersionControl = SnippetOptionWidget.extend({ }); }); await new Promise(resolve => { - this.trigger_up("remove_snippet", - {$snippet: this.$target, onSuccess: resolve, shouldRecordUndo: false} - ); + this.env.removeSnippet({ + data: {$snippet: this.$target, onSuccess: resolve, shouldRecordUndo: false}, + stopPropagation: (ev) => {}, + }); }); this.options.wysiwyg.odooEditor.historyUnpauseSteps(); newBlockEl.classList.remove("oe_snippet_body"); this.options.wysiwyg.odooEditor.historyStep(); }); - }, + } /** * Allows to still access the options of an outdated block, despite the * warning. */ discardAlert() { - const alertEl = this.$el[0].querySelector("we-alert"); + this.renderContext.showAlert = false; const optionsSectionEl = this.$overlay.data("$optionsSection")[0]; - alertEl.remove(); optionsSectionEl.classList.remove("o_we_outdated_block_options"); // Preventing the alert to reappear at each render. controlledSnippets.add(this.$target[0].dataset.snippet); - }, + } //-------------------------------------------------------------------------- // Private @@ -9392,32 +9914,40 @@ registry.VersionControl = SnippetOptionWidget.extend({ /** * @override */ - _renderCustomXML(uiFragment) { + async _getRenderContext() { + const context = await super._getRenderContext(); const snippetName = this.$target[0].dataset.snippet; // Do not display the alert if it was previously discarded. - if (controlledSnippets.has(snippetName)) { - return; + if (! controlledSnippets.has(snippetName)) { + this.env.getSnippetVersions({ + snippetName: snippetName, + onSuccess: snippetVersions => { + const isUpToDate = snippetVersions && ["vjs", "vcss", "vxml"].every(key => this.$target[0].dataset[key] === snippetVersions[key]); + if (!isUpToDate) { + context.showAlert = true; + // uiFragment.prepend(renderToElement("web_editor.outdated_block_message")); + // Hide the other options, to only have the alert displayed. + const optionsSectionEl = this.$overlay.data("$optionsSection")[0]; + optionsSectionEl.classList.add("o_we_outdated_block_options"); + } + }, + }); } - this.trigger_up("get_snippet_versions", { - snippetName: snippetName, - onSuccess: snippetVersions => { - const isUpToDate = snippetVersions && ["vjs", "vcss", "vxml"].every(key => this.$target[0].dataset[key] === snippetVersions[key]); - if (!isUpToDate) { - uiFragment.prepend(renderToElement("web_editor.outdated_block_message")); - // Hide the other options, to only have the alert displayed. - const optionsSectionEl = this.$overlay.data("$optionsSection")[0]; - optionsSectionEl.classList.add("o_we_outdated_block_options"); - } - }, - }); - }, + return context; + } +} + +registerOption("VersionControl", { + Class: VersionControl, + template: "web_editor.outdated_block_message_option", + selector: "[data-snippet]", }); /** * Handle the save of a snippet as a template that can be reused later */ -registry.SnippetSave = SnippetOptionWidget.extend({ - isTopOption: true, +export class SnippetSave extends SnippetOption { + static isTopOption = true; //-------------------------------------------------------------------------- // Options @@ -9426,7 +9956,7 @@ registry.SnippetSave = SnippetOptionWidget.extend({ /** * @see this.selectClass for parameters */ - saveSnippet: function (previewMode, widgetValue, params) { + saveSnippet(previewMode, widgetValue, params) { return new Promise(resolve => { this.dialog.add(ConfirmationDialog, { body: _t("To save a snippet, we need to save all your previous modifications and reload the page."), @@ -9437,21 +9967,17 @@ registry.SnippetSave = SnippetOptionWidget.extend({ const isButton = this.$target[0].matches("a.btn"); const snippetKey = !isButton ? this.$target[0].dataset.snippet : "s_button"; let thumbnailURL; - this.trigger_up('snippet_thumbnail_url_request', { + this.env.snippetThumbnailUrlRequest({ key: snippetKey, onSuccess: url => thumbnailURL = url, }); - let context; - this.trigger_up('context_get', { - callback: ctx => context = ctx, - }); if (this.$target[0].matches("[data-snippet=s_popup]")) { // Do not "cleanForSave" the popup before copying the // HTML, otherwise the popup will be saved invisible and // therefore not visible in the "add snippet" dialog. targetCopyEl = this.$target[0].cloneNode(true); } - this.trigger_up('request_save', { + this.env.requestSave({ reloadEditor: true, invalidateSnippetCache: true, onSuccess: async () => { @@ -9478,20 +10004,16 @@ registry.SnippetSave = SnippetOptionWidget.extend({ break; } } - context['model'] = editableParentEl.dataset.oeModel; - context['field'] = editableParentEl.dataset.oeField; - context['resId'] = editableParentEl.dataset.oeId; - await rpc(`/web/dataset/call_kw/ir.ui.view/save_snippet`, { - model: "ir.ui.view", - method: "save_snippet", - args: [], - kwargs: { - 'name': defaultSnippetName, - 'arch': targetCopyEl.outerHTML, - 'template_key': this.options.snippets, - 'snippet_key': snippetKey, - 'thumbnail_url': thumbnailURL, - 'context': context, + await this.env.services.orm.call("ir.ui.view", "save_snippet", [], { + 'name': defaultSnippetName, + 'arch': targetCopyEl.outerHTML, + 'template_key': this.options.snippets, + 'snippet_key': snippetKey, + 'thumbnail_url': thumbnailURL, + 'context': { + 'model': editableParentEl.dataset.oeModel, + 'field': editableParentEl.dataset.oeField, + 'resId': editableParentEl.dataset.oeId, }, }); }, @@ -9500,27 +10022,13 @@ registry.SnippetSave = SnippetOptionWidget.extend({ }, }); }); - }, -}); + } +} /** * 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); - }, +export class DynamicSvg extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -9549,7 +10057,7 @@ registry.DynamicSvg = SnippetOptionWidget.extend({ if (!previewMode) { this.previousSrc = src; } - }, + } //-------------------------------------------------------------------------- // Private @@ -9563,8 +10071,8 @@ registry.DynamicSvg = SnippetOptionWidget.extend({ case 'color': return new URL(this.$target[0].src, window.location.origin).searchParams.get(params.colorName); } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -9572,31 +10080,26 @@ registry.DynamicSvg = SnippetOptionWidget.extend({ if ('colorName' in params) { return new URL(this.$target[0].src, window.location.origin).searchParams.get(params.colorName); } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @override */ _computeVisibility(methodName, params) { return this.$target.is("img[src^='/web_editor/shape/']"); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- + } +} - /** - * @override - */ - _onImageChanged(methodName, params) { - return this.updateUI(); - }, +registerOption("DynamicSvg", { + Class: DynamicSvg, + template: "web_editor.dynamic_svg_option", + selector: "img", }); /** * Allows to handle snippets with a list of items. */ -registry.MultipleItems = SnippetOptionWidget.extend({ +export class MultipleItems extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -9610,22 +10113,17 @@ registry.MultipleItems = SnippetOptionWidget.extend({ const addBeforeItem = params.addBefore === 'true'; if ($target.length) { await new Promise(resolve => { - this.trigger_up('clone_snippet', { - $snippet: $target, - onSuccess: resolve, - }); + this.env.cloneSnippet($target).then(resolve); }); if (addBeforeItem) { $target.before($target.next()); } if (params.selectItem !== 'false') { - this.trigger_up('activate_snippet', { - $snippet: addBeforeItem ? $target.prev() : $target.next(), - }); + this.env.activateSnippet(addBeforeItem ? $target.prev() : $target.next()); } this._addItemCallback($target); } - }, + } /** * @see this.selectClass for parameters */ @@ -9633,14 +10131,17 @@ registry.MultipleItems = SnippetOptionWidget.extend({ const $target = this.$(params.item); if ($target.length) { await new Promise(resolve => { - this.trigger_up('remove_snippet', { - $snippet: $target, - onSuccess: resolve, + this.env.removeSnippet({ + data: { + $snippet: $target, + onSuccess: resolve, + }, + stopPropagation: (ev) => {}, }); }); this._removeItemCallback($target); } - }, + } //-------------------------------------------------------------------------- // Private @@ -9653,37 +10154,45 @@ registry.MultipleItems = SnippetOptionWidget.extend({ * @abstract * @param {jQueryElement} $target */ - _addItemCallback($target) {}, + _addItemCallback($target) {} /** * @private * @abstract * @param {jQueryElement} $target */ - _removeItemCallback($target) {}, -}); + _removeItemCallback($target) {} +} -registry.SelectTemplate = SnippetOptionWidget.extend({ - custom_events: Object.assign({}, SnippetOptionWidget.prototype.custom_events, { - 'user_value_widget_opening': '_onWidgetOpening', - }), +export class SelectTemplateComponent extends SnippetOptionComponent { + setup() { + super.setup(); + useChildSubEnv({ + userValueWidgetOpening: (userValue) => { + this.env.userValueWidgetOpening(userValue); + this.props.snippetOption.instance.onUserValueWidgetOpening(userValue); + }, + }); + } +} +export class SelectTemplate extends SnippetOption { /** - * @constructor + * @override */ - init() { - this._super(...arguments); + static defaultRenderingComponent = SelectTemplateComponent; + + constructor() { + super(...arguments); this.containerSelector = ''; this.selectTemplateWidgetName = ''; - this.orm = this.bindService("orm"); - }, - /** - * @constructor - */ - async start() { + this.orm = this.env.services.orm; + } + + async willStart() { + await super.willStart(...arguments); this.containerEl = this.containerSelector ? this.$target.find(this.containerSelector)[0] : this.$target[0]; this._templates = {}; - return this._super(...arguments); - }, + } //-------------------------------------------------------------------------- // Options @@ -9732,7 +10241,7 @@ registry.SelectTemplate = SnippetOptionWidget.extend({ // added by other options or custo). this.beforePreviewNodes = null; } - }, + } //-------------------------------------------------------------------------- // Private @@ -9743,7 +10252,7 @@ registry.SelectTemplate = SnippetOptionWidget.extend({ * * @private * @param {string} xmlid - * @returns {string} + * @returns {Promise} */ async _getTemplate(xmlid) { if (!this._templates[xmlid]) { @@ -9755,21 +10264,20 @@ registry.SelectTemplate = SnippetOptionWidget.extend({ ); } return this._templates[xmlid]; - }, + } //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** - * @private - * @param {OdooEvent} ev + * @override */ - _onWidgetOpening(ev) { - if (this._templatesLoading || ev.target.getName() !== this.selectTemplateWidgetName) { + onUserValueWidgetOpening(userValue) { + if (this._templatesLoading || userValue.getName() !== this.selectTemplateWidgetName) { return; } - const templateParams = ev.target.getMethodsParams('selectTemplate'); + const templateParams = userValue.getMethodsParams('selectTemplate'); const proms = templateParams.possibleValues.map(async xmlid => { if (!xmlid) { return; @@ -9780,16 +10288,15 @@ registry.SelectTemplate = SnippetOptionWidget.extend({ await this._getTemplate(xmlid); }); this._templatesLoading = Promise.all(proms); - }, -}); + } +} /* * Abstract option to be extended by the Carousel and gallery options (through * the "CarouselHandler" option) that handles all the common parts (reordering * of elements). */ -registry.GalleryHandler = SnippetOptionWidget.extend({ - +export class GalleryHandler extends SnippetOption { //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- @@ -9800,7 +10307,7 @@ registry.GalleryHandler = SnippetOptionWidget.extend({ * @override */ notify(name, data) { - this._super(...arguments); + super.notify(...arguments); if (name === "reorder_items") { const itemsEls = this._getItemsGallery(); const oldPosition = itemsEls.indexOf(data.itemEl); @@ -9826,7 +10333,7 @@ registry.GalleryHandler = SnippetOptionWidget.extend({ } this._reorderItems(itemsEls, itemsEls.indexOf(data.itemEl)); } - }, + } //-------------------------------------------------------------------------- // Private @@ -9839,7 +10346,7 @@ registry.GalleryHandler = SnippetOptionWidget.extend({ * @abstract * @returns {HTMLElement[]} */ - _getItemsGallery() {}, + _getItemsGallery() {} /** * Called to reorder the items of the gallery. * @@ -9847,15 +10354,14 @@ registry.GalleryHandler = SnippetOptionWidget.extend({ * @param {HTMLElement[]} itemsEls - the items to reorder. * @param {integer} newItemPosition - the new position of the moved items. */ - _reorderItems(itemsEls, newItemPosition) {}, -}); + _reorderItems(itemsEls, newItemPosition) {} +} /* * Abstract option to be extended by the Carousel and gallery options that * handles the update of the carousel indicator. */ -registry.CarouselHandler = registry.GalleryHandler.extend({ - +export class CarouselHandler extends GalleryHandler { //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -9877,36 +10383,48 @@ registry.CarouselHandler = registry.GalleryHandler.extend({ } this.$target[0].querySelector(`.carousel-indicators li[data-bs-slide-to="${position}"]`) .classList.add("active"); - this.trigger_up("activate_snippet", { - $snippet: $(this.$target[0].querySelector(".carousel-item.active img")), - ifInactiveOptions: true, - }); + this.env.activateSnippet($(this.$target[0].querySelector(".carousel-item.active img")), false, true); carouselEl.classList.add("slide"); - }, -}); - - -export default { - SnippetOptionWidget: SnippetOptionWidget, - snippetOptionRegistry: registry, - - NULL_ID: NULL_ID, - UserValueWidget: UserValueWidget, - userValueWidgetsRegistry: userValueWidgetsRegistry, - UnitUserValueWidget: UnitUserValueWidget, - - addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes, - buildElement: _buildElement, - buildTitleElement: _buildTitleElement, - buildRowElement: _buildRowElement, - buildCollapseElement: _buildCollapseElement, + } +} - addAnimatedShapeLabel: _addAnimatedShapeLabel, +export function registerBackgroundOptions(name, options, getTemplateName = () => null) { + registerOption(`${name}-bgToggler`, { + Class: ( + options.withColors + && options.withColorCombinations + && ColoredLevelBackground + ) || BackgroundToggler, + template: getTemplateName("toggler") || "web_editor.snippet_options_background_options", + ...options + }, { sequence: 25 }); + if (options.withImages) { + registerOption(`${name}-bgImg`, { + Class: BackgroundImage, + template: getTemplateName("img") || "web_editor.BackgroundImage", + ...options + }, { sequence: 27 }); + registerOption(`${name}-bgPosition`, { + Class: BackgroundPosition, + template: getTemplateName("position") || "web_editor.BackgroundPosition", + ...options, + }, { sequence: 29 }); + registerOption(`${name}-bgOptimize`, { + Class: BackgroundOptimize, + template: getTemplateName("optimize") || "web_editor.snippet_options_image_optimization_widgets", + indent: true, + ...options, + }, { sequence: 31 }); + registerOption(`${name}-filter`, { + Class: options.withColors && options.withColorCombinations && ColoredLevelBackground || BackgroundToggler, + template: getTemplateName("filter") || "web_editor.BackgroundFilter", + ...options, + }, { sequence: 33 }); + registerOption(`${name}-shape`, { + Class: BackgroundShape, + template: getTemplateName("shape") || "web_editor.BackgroundShapes", + ...options, + }, { sequence: 35 }); + } +} - // Other names for convenience - Class: SnippetOptionWidget, - registry: registry, - serviceCached, - clearServiceCache, - clearControlledSnippets, -}; diff --git a/addons/web_editor/static/src/js/editor/snippets.options.legacy.js b/addons/web_editor/static/src/js/editor/snippets.options.legacy.js new file mode 100644 index 0000000000000..1feb705c10f4f --- /dev/null +++ b/addons/web_editor/static/src/js/editor/snippets.options.legacy.js @@ -0,0 +1,9789 @@ +/** @odoo-module **/ + +import { attachComponent } from "@web/legacy/utils"; +import { MediaDialog } from "@web_editor/components/media_dialog/media_dialog"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import dom from "@web/legacy/js/core/dom"; +import { throttleForAnimation, debounce } from "@web/core/utils/timing"; +import { clamp } from "@web/core/utils/numbers"; +import Widget from "@web/legacy/js/core/widget"; +import { ColorPalette } from "@web_editor/js/wysiwyg/widgets/color_palette"; +import weUtils from "@web_editor/js/common/utils"; +import * as gridUtils from "@web_editor/js/common/grid_layout_utils"; +import {ColumnLayoutUtils} from "@web_editor/js/common/column_layout_mixin"; +const { + normalizeColor, + getBgImageURL, + backgroundImageCssToParts, + backgroundImagePartsToCss, + DEFAULT_PALETTE, + isBackgroundImageAttribute, +} = weUtils; +import { ImageCrop } from '@web_editor/js/wysiwyg/widgets/image_crop'; +import { + loadImage, + loadImageInfo, + applyModifications, + removeOnImageChangeAttrs, + isImageSupportedForProcessing, + isImageSupportedForStyle, + createDataURL, + isGif, + getDataURLBinarySize, +} from "@web_editor/js/editor/image_processing"; +import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor"; +import { pick } from "@web/core/utils/objects"; +import { _t } from "@web/core/l10n/translation"; +import { + isCSSColor, + convertCSSColorToRgba, + normalizeCSSColor, + } from '@web/core/utils/colors'; +import { renderToElement } from "@web/core/utils/render"; +import { rpc } from "@web/core/network/rpc"; + +const preserveCursor = OdooEditorLib.preserveCursor; +const { DateTime } = luxon; +const resetOuids = OdooEditorLib.resetOuids; +let _serviceCache = { + orm: {}, + rpc: {}, +}; +const clearServiceCache = () => { + _serviceCache = { + orm: {}, + rpc: {}, + }; +}; + +// Regex definitions to apply speed modification in SVG files +// Note : These regex patterns are duplicated on the server side for +// background images that are part of a CSS rule "background-image: ...". The +// client-side regex patterns are used for images that are part of an +// "src" attribute with a base64 encoded svg in the tag. Perhaps we should +// consider finding a solution to define them only once? The issue is that the +// regex patterns in Python are slightly different from those in JavaScript. +// See : controllers/main.py +const CSS_ANIMATION_RULE_REGEX = + /(?animation(?:-duration)?: .*?)(?(?:\d+(?:\.\d+)?)|(?:\.\d+))(?ms|s)(?\s|;|"|$)/gm; +const SVG_DUR_TIMECOUNT_VAL_REGEX = + /(?\sdur="\s*)(?(?:\d+(?:\.\d+)?)|(?:\.\d+))(?h|min|ms|s)?\s*"/gm; +const CSS_ANIMATION_RATIO_REGEX = /(--animation_ratio: (?\d*(\.\d+)?));/m; +/** + * Caches rpc/orm service + * @param {Function} service + * @param {...any} args + * @returns + */ +function serviceCached(service) { + const cache = _serviceCache; + return Object.assign(Object.create(service), { + call() { + // FIXME + const serviceName = Object.prototype.hasOwnProperty.call(service, "call") + ? "orm" + : "rpc"; + const cacheId = JSON.stringify(arguments); + if (!cache[serviceName][cacheId]) { + cache[serviceName][cacheId] = + serviceName == "rpc" ? service(...arguments) : service.call(...arguments); + } + return cache[serviceName][cacheId]; + }, + }); +} +// Outdated snippets whose alert has been discarded. +const controlledSnippets = new Set(); +const clearControlledSnippets = () => controlledSnippets.clear(); +/** + * @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.dataAttributes && options.dataAttributes.fontFamily) { + titleEl.style.fontFamily = options.dataAttributes.fontFamily; + } + } + + 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 () => { + let text; + if (src.split('.').pop() === 'svg') { + try { + const response = await window.fetch(src); + text = await response.text(); + } catch { + // In some tours, the tour finishes before the fetch is done + // and when a tour is finished, the python side will ask the + // browser to stop loading resources. This causes the fetch + // to fail and throw an error which crashes the test even + // though it completed successfully. + // So return an empty SVG to ensure everything completes + // correctly. + 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(); + titleEl.classList.add('o_we_collapse_toggler'); + 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); + }, + }); +} +/** + * Creates and registers a UserValueWidget by tag-name + * + * @param {string} widgetName + * @param {SnippetOptionWidget|UserValueWidget|null} parent + * @param {string} title + * @param {Object} options + * @returns {UserValueWidget} + */ +function registerUserValueWidget(widgetName, parent, title, options, $target) { + const widget = new userValueWidgetsRegistry[widgetName](parent, title, options, $target); + parent.registerSubWidget(widget); + return widget; +} + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +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.illustrationEl = await _buildImgElement(this.options.dataAttributes.img); + } else if (this.options.dataAttributes.icon) { + this.illustrationEl = document.createElement('i'); + this.illustrationEl.classList.add('fa', this.options.dataAttributes.icon); + } + if (this.options.dataAttributes.reload) { + this.options.dataAttributes.noPreview = "true"; + } + }, + /** + * @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.illustrationEl) { + this.containerEl.appendChild(this.illustrationEl); + } + + 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; + } + if (!this.el.classList.contains('o_we_widget_opened')) { + // Small optimization: it would normally not matter asking to + // remove a class of an element if it does not already have it but + // in this case we do more: we trigger_up an event and ask to close + // all sub widgets. When we ask the editor to close all widgets... + // it makes sense not letting every sub button of every select + // trigger_up an event. This allows to avoid tens of thousands of + // instructions being done at each click in the editor. + 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; + }, + /** + * Focus the main focusable element of the widget. + */ + focus() { + const el = this._getFocusableElement(); + if (el) { + el.focus(); + } + }, + /** + * 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 = Object.assign({}, 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 = Object.assign({}, 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 = Object.assign({}, 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; + } + + // Method names come from the widget's dataset whose keys' order cannot + // be relied on. We explicitely sort them by alphabetical order allowing + // consistent behavior, while relying on order for such methods should + // not be done when possible (the methods should be independent from + // each other when possible). + this._methodsNames.sort(); + }, + /** + * @param {boolean} [previewMode=false] + * @param {boolean} [isSimulatedEvent=false] + */ + notifyValueChange: function (previewMode, isSimulatedEvent) { + // 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) { + let doFocus = false; + if (show) { + const wasInvisible = this.el.classList.contains('d-none'); + doFocus = wasInvisible && this.el.dataset.requestFocus === "true"; + } + this.el.classList.toggle('d-none', !show); + if (doFocus) { + this.focus(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the main focusable element of the widget. By default supposes + * nothing is focusable. + * + * @todo review all specific widget's method + * @private + * @returns {HTMLElement} + */ + _getFocusableElement: function () { + return null; + }, + /** + * @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 + */ + async willStart() { + await this._super(...arguments); + if (this.options.dataAttributes.activeImg) { + this.activeImgEl = await _buildImgElement(this.options.dataAttributes.activeImg); + } + }, + /** + * @override + */ + _makeDescriptive() { + const $el = this._super(...arguments); + if (this.illustrationEl) { + $el[0].classList.add('o_we_icon_button'); + } + if (this.activeImgEl) { + this.containerEl.appendChild(this.activeImgEl); + } + return $el; + }, + /** + * @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); + } + if (this.illustrationEl && this.activeImgEl) { + this.illustrationEl.classList.toggle('d-none', active); + this.activeImgEl.classList.toggle('d-none', !active); + } + 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 => { + // Ensure to only put element nodes inside the selection menu + // as there could be an :empty CSS rule to handle the case when + // the menu is empty (so it should not contain any whitespace). + if (node.nodeType === Node.ELEMENT_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. + break; + } + } + 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', + }, + PLACEHOLDER_TEXT: _t("None"), + + /** + * @override + */ + async start() { + await this._super(...arguments); + + if (this.options && this.options.valueEl) { + this.containerEl.insertBefore(this.options.valueEl, this.menuEl); + } + + this.menuEl.dataset.placeholderText = this.PLACEHOLDER_TEXT; + + this.menuTogglerEl = document.createElement('we-toggler'); + this.menuTogglerEl.dataset.placeholderText = this.PLACEHOLDER_TEXT; + this.iconEl = this.illustrationEl || null; + const icon = this.el.dataset.icon; + if (icon) { + this.iconEl = document.createElement('i'); + this.iconEl.classList.add('fa', 'fa-fw', icon); + } + if (this.iconEl) { + this.el.classList.add('o_we_icon_select'); + this.menuTogglerEl.appendChild(this.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); + this.el.classList.remove("o_we_select_dropdown_up"); + 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'); + this._adjustDropdownPosition(); + }, + /** + * @override + */ + async setValue() { + await this._super(...arguments); + + if (this.iconEl) { + 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; + const icon = activeWidget.el.dataset.icon; + if (value) { + textContent = value; + } else if (icon) { + this.menuTogglerItemEl = document.createElement('i'); + this.menuTogglerItemEl.classList.add('fa', icon); + } 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.PLACEHOLDER_TEXT; + } + + this.menuTogglerEl.textContent = textContent; + if (this.menuTogglerItemEl) { + this.menuTogglerEl.appendChild(this.menuTogglerItemEl); + } + }, + /** + * @override + */ + enable() { + if (!this.menuTogglerEl.classList.contains('active')) { + this.menuTogglerEl.click(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _shouldIgnoreClick(ev) { + return !!ev.target.closest('[role="button"]'); + }, + /** + * Decides whether the dropdown should be positioned below or above the + * selector based on the available space. + * + * @private + */ + _adjustDropdownPosition() { + const customizePanelEl = this.menuEl.closest(".o_we_customize_panel"); + if (!customizePanelEl) { + return; + } + + this.el.classList.remove("o_we_select_dropdown_up"); + const customizePanelElCoords = customizePanelEl.getBoundingClientRect(); + let dropdownMenuElCoords = this.menuEl.getBoundingClientRect(); + + // Adds a margin to prevent the dropdown from sticking to the edge of + // the customize panel. + const dropdownMenuMargin = 5; + // If after opening, the dropdown list overflows the customization + // panel at the bottom, opens the dropdown above the selector. + if ((dropdownMenuElCoords.bottom + dropdownMenuMargin) > customizePanelElCoords.bottom) { + this.el.classList.add("o_we_select_dropdown_up"); + dropdownMenuElCoords = this.menuEl.getBoundingClientRect(); + // If there is no available space above it either, then we open + // it below the selector. + if (dropdownMenuElCoords.top < customizePanelElCoords.top) { + this.el.classList.remove("o_we_select_dropdown_up"); + } + } + }, + + //-------------------------------------------------------------------------- + // 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 (!this._isNumeric()) { + 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 (!this._isNumeric()) { + 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); + if (!this._isNumeric()) { + return isSuperActive; + } + return isSuperActive && ( + this._floatToStr(parseFloat(this._value)) !== '0' + // Or is a composite value. + || !!this._value.match(/\d+\s+\d+/) + ); + }, + /** + * @override + */ + async setValue(value, methodName) { + const params = this._methodsParams; + if (this._isNumeric()) { + 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))}`; + }, + /** + * Checks whether the widget contains a numeric value. + * + * @private + * @returns {Boolean} true if the value is numeric, false otherwise. + */ + _isNumeric() { + const params = this._methodsParams || this.el.dataset; + return !!params.unit; + }, +}); + +const InputUserValueWidget = UnitUserValueWidget.extend({ + tagName: 'we-input', + events: { + 'input input': '_onInputInput', + 'blur input': '_onInputBlur', + 'change input': '_onUserValueChange', + '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') || ''); + const useNumberAlignment = this._isNumeric() || !!this.el.dataset.hideUnit; + this.inputEl.classList.toggle('text-start', !useNumberAlignment); + this.inputEl.classList.toggle('text-end', useNumberAlignment); + this.containerEl.appendChild(this.inputEl); + + const showUnit = (!!unit || !!this.el.dataset.fakeUnit) && !this.el.dataset.hideUnit; + if (showUnit) { + var unitEl = document.createElement('span'); + const unitText = this.el.dataset.fakeUnit || unit; + unitEl.textContent = unitText; + this.containerEl.appendChild(unitEl); + if (unitText.length > 3) { + this.el.classList.add('o_we_large'); + } + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async setValue() { + await this._super(...arguments); + this.inputEl.value = this._value; + this._oldValue = this._value; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getFocusableElement() { + return this.inputEl; + }, + /** + * @override + */ + _isNumeric() { + const isNumeric = this._super(...arguments); + const params = this._methodsParams || this.el.dataset; + return isNumeric || !!params.fakeUnit || !!params.step; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onInputInput: function (ev) { + // First record the input value as the new current value and bound it if + // necessary (min / max params). + this._value = this.inputEl.value; + + const params = this._methodsParams; + const hasMin = ('min' in params); + const hasMax = ('max' in params); + if (hasMin || hasMax) { + // Bounding the value in [min, max] if specified. + const boundedValue = this._value.split(/\s+/g).map(v => { + let numValue = parseFloat(v); + if (isNaN(numValue)) { + return hasMin ? params.min : v; + } else { + numValue = hasMin ? Math.max(params.min, numValue) : numValue; + numValue = hasMax ? Math.min(numValue, params.max) : numValue; + return numValue; + } + }).join(" "); + + // If the bounded version is different from the value, forget about + // the old value so that we properly update the UI in any case. + this._oldValue = undefined; + + // Note: we do not change the input's value because we want the user + // to be able to enter anything without it being auto-fixed. For + // example, just emptying the input to enter new numbers: you don't + // want the min value to pop up unexpectedly. The next UI update + // will take care of showing the user that the value was bound. + this._value = boundedValue; + } + + // When the value changes as a result of a arrow up/down, the change + // event is not called, unless a real user input has been triggered. + // This event handler holds a variable for this in order to not call + // `_onUserValueChange` two times. If the users only uses arrow up/down + // it will be trigger on blur otherwise it will be triggered on change. + if (!ev.detail || !ev.detail.keyUpOrDown) { + this.changeEventWillBeTriggered = true; + } + this._onUserValuePreview(ev); + }, + /** + * @private + * @param {Event} ev + */ + _onInputBlur: function (ev) { + if (this.notifyValueChangeOnBlur && !this.changeEventWillBeTriggered) { + // In case the input value has been modified with arrow up/down, the + // change event is not triggered (except if there has been a natural + // input event), so if the element doesn't trigger a preview, we + // have to notify that the value changes now. + this._onUserValueChange(ev); + this.notifyValueChangeOnBlur = false; + } + this.changeEventWillBeTriggered = false; + }, + /** + * @private + * @param {Event} ev + */ + _onInputKeydown: function (ev) { + const params = this._methodsParams; + if (!this._isNumeric()) { + return; + } + switch (ev.key) { + case "Enter": + this._onUserValueChange(ev); + break; + case "ArrowUp": + case "ArrowDown": { + const input = ev.currentTarget; + let parts = (input.value || input.placeholder).match(/-?\d+\.\d+|-?\d+/g); + if (!parts) { + parts = [input.value || input.placeholder]; + } + if (parts.length > 1 && !('min' in params)) { + // No negative for composite values. + params['min'] = 0; + } + const newValue = parts.map(part => { + let value = parseFloat(part); + if (isNaN(value)) { + value = 0.0; + } + let step = parseFloat(params.step); + if (isNaN(step)) { + step = 1.0; + } + + const increasing = ev.key === "ArrowUp"; + const hasMin = ('min' in params); + const hasMax = ('max' in params); + + // If value already at min and trying to decrease, do nothing + if (!increasing && hasMin && Math.abs(value - params.min) < 0.001) { + return value; + } + // If value already at max and trying to increase, do nothing + if (increasing && hasMax && Math.abs(value - params.max) < 0.001) { + return value; + } + + // If trying to decrease/increase near min/max, we still need to + // bound the produced value and immediately show the user. + value += (increasing ? step : -step); + value = hasMin ? Math.max(params.min, value) : value; + value = hasMax ? Math.min(value, params.max) : value; + return this._floatToStr(value); + }).join(" "); + if (newValue === (input.value || input.placeholder)) { + return; + } + input.value = newValue; + + // We need to know if the change event will be triggered or not. + // Change is triggered if there has been a "natural" input event + // from the user. Since we are triggering a "fake" input event, + // we specify that the original event is a key up/down. + input.dispatchEvent(new CustomEvent('input', { + bubbles: true, + cancelable: true, + detail: {keyUpOrDown: true} + })); + this.notifyValueChangeOnBlur = true; + break; + } + } + }, + /** + * @override + */ + _onUserValueChange() { + if (this._oldValue !== this._value) { + this._super(...arguments); + } + } +}); + +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', + + /** + * @override + */ + start: async function () { + const _super = this._super.bind(this); + const args = arguments; + + this.resetTabCount = 0; + + // 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'); + // todo: This div should be removed whenever possible (like when + // converting the uservaluewidget to owl). + this.colorPaletteEl = document.createElement('div'); + this.colorPaletteEl.classList.add('o_we_color_palette_wrapper'); + this.colorPaletteEl.style.display = 'contents'; + this.colorPaletteColorNames = []; + this.options.childNodes = [this.colorPaletteEl]; + this.options.valueEl = this.colorPreviewEl; + // TODO: find a better way to do this. + // The colorpicker widget is started before the ColorPalette component + // is attached to the DOM (which only happens once the user opens the + // picker). However, the colorNames are only set after the ColorPalette + // has been mounted. Initializing the colorNames through a direct call + // to the `getColorPickerTemplateService` so that the widget starts + // with possible default values is thus necessary to avoid bugs on + // `_computeWidgetState()`. + const wysiwyg = this.getParent().options.wysiwyg; + if (wysiwyg) { + const colorpickerTemplate = await wysiwyg.getColorpickerTemplate.call(wysiwyg); + this.colorPaletteColorNames = this._getColorNames(colorpickerTemplate); + } + return _super(...args); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + open: function () { + if (this.colorPaletteWrapper) { + this.colorPaletteWrapper?.update({ + selectedCC: this._ccValue, + selectedColor: this._value, + resetTabCount: ++this.resetTabCount, + }); + this._super(...arguments); + } 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); + this._colorPaletteRenderPromise.then(() => { + // Re-adjust the position of the colorpicker once the + // colorpalette is completely rendered (once that the + // colorpicker has its final height. + // TODO should not be needed once everything will be converted + // to owl. + this._adjustDropdownPosition(); + }); + } + }, + /** + * @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 Object.assign(this._super(...arguments), { + colorNames: this.colorPaletteColorNames, + }); + }, + /** + * @override + */ + getValue: function (methodName) { + const isCCMethod = (this._methodsParams.withCombinations === methodName); + let value = this._super(...arguments); + if (isCCMethod) { + value = this._ccValue; + } else if (typeof this._customColorValue === 'string') { + value = this._customColorValue; + } + + // TODO strange there is some processing below for the normal value but + // not for the preview value? To check in older stable versions as well. + if (typeof this._previewColor === 'string') { + return isCCMethod ? this._previewCC : this._previewColor; + } + + if (value) { + // TODO probably something to be done to handle gradients properly + // in this code. + const useCssColor = this.options.dataAttributes.hasOwnProperty('useCssColor'); + const cssCompatible = this.options.dataAttributes.hasOwnProperty('cssCompatible'); + if ((useCssColor || cssCompatible) && !isCSSColor(value)) { + if (useCssColor) { + value = weUtils.getCSSVariableValue(value); + } else { + value = `var(--${value})`; + } + } + } + return value; + }, + /** + * @override + */ + isContainer: function () { + return false; + }, + /** + * @override + */ + isActive: function () { + return !!this._ccValue + || !weUtils.areCssValuesEqual(this._value, 'rgba(0, 0, 0, 0)'); + }, + /** + * Updates the color preview + re-render the whole color palette widget. + * + * @override + */ + async setValue(color, methodName, ...rest) { + // The colorpicker widget can hold two values: a color combination and + // a normal color or a gradient. The base `_value` will hold the normal + // color or the gradient value. The color combination one will be + // available in `_ccValue`. + const isCCMethod = (this._methodsParams.withCombinations === methodName); + // Always call _super but don't change _value if meant for the CC value. + await this._super(isCCMethod ? this._value : color, methodName, ...rest); + if (isCCMethod) { + this._ccValue = color; + } + + await this._colorPaletteRenderPromise; + + const classes = weUtils.computeColorClasses(this.colorPaletteColorNames); + this.colorPreviewEl.classList.remove(...classes); + this.colorPreviewEl.style.removeProperty('background-color'); + this.colorPreviewEl.style.removeProperty('background-image'); + const prefix = this.options.dataAttributes.colorPrefix || 'bg'; + if (this._ccValue) { + this.colorPreviewEl.style.backgroundColor = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')})`; + this.colorPreviewEl.style.backgroundImage = `var(--we-cp-o-cc${this._ccValue}-${prefix.replace(/-/, '')}-gradient)`; + } + if (this._value) { + this.colorPreviewEl.style.backgroundImage = 'none'; + if (isCSSColor(this._value)) { + this.colorPreviewEl.style.backgroundColor = this._value; + } else if (weUtils.isColorGradient(this._value)) { + this.colorPreviewEl.style.backgroundImage = this._value; + } else if (weUtils.EDITOR_COLOR_CSS_VARIABLES.includes(this._value)) { + this.colorPreviewEl.style.backgroundColor = `var(--we-cp-${this._value}`; + } else { + // Checking if the className actually exists seems overkill but + // it is actually needed to prevent a crash. As an example, if a + // colorpicker widget is linked to a SnippetOption instance's + // `selectStyle` method designed to handle the "border-color" + // property of an element, the value received can be split if + // the item uses different colors for its top/right/bottom/left + // borders. For instance, you could receive "red blue" if the + // item as red top and bottom borders and blue left and right + // borders, in which case you would reach this `else` and try to + // add the class "bg-red blue" which would crash because of the + // space inside). In that case, we simply do not show any color. + // We could choose to handle this split-value case specifically + // but it was decided that this is enough for the moment. + const className = `bg-${this._value}`; + if (classes.includes(className)) { + this.colorPreviewEl.classList.add(className); + } + } + } + // If the palette was already opened (e.g. modifying a gradient), the new DOM state must be + // reflected in the palette, but the tab selection must not be impacted. + this.colorPaletteWrapper?.update({ + selectedCC: this._ccValue, + selectedColor: this._value, + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {Promise} + */ + _renderColorPalette: async function () { + this.resetTabCount = 0; + const options = { + resetTabCount: this.resetTabCount, + selectedCC: this._ccValue, + selectedColor: this._value, + getCustomColors: () => { + let result = []; + this.trigger_up('get_custom_colors', { + onSuccess: (colors) => result = colors, + }); + return result; + }, + onCustomColorPicked: this._onCustomColorPicked.bind(this), + onColorPicked: this._onColorPicked.bind(this), + onColorHover: this._onColorHovered.bind(this), + onColorLeave: this._onColorLeft.bind(this), + onInputEnter: this._onEnterKey.bind(this), + }; + if (this.options.dataAttributes.excluded) { + options.excluded = this.options.dataAttributes.excluded.replace(/ /g, '').split(','); + } + if (this.options.dataAttributes.opacity) { + options.opacity = parseFloat(this.options.dataAttributes.opacity); + } + if (this.options.dataAttributes.withCombinations) { + options.withCombinations = !!this.options.dataAttributes.withCombinations; + } + if (this.options.dataAttributes.withGradients) { + options.withGradients = !!this.options.dataAttributes.withGradients; + } + if (this.options.dataAttributes.noTransparency) { + options.noTransparency = !!this.options.dataAttributes.noTransparency; + options.excluded = [...(options.excluded || []), 'transparent_grayscale']; + } + if (this.options.dataAttributes.selectedTab) { + options.selectedTab = this.options.dataAttributes.selectedTab; + } + const wysiwyg = this.getParent().options.wysiwyg; + if (wysiwyg) { + options.document = this.$target[0].ownerDocument; + options.getTemplate = wysiwyg.getColorpickerTemplate.bind(wysiwyg); + } + this.colorPaletteWrapper?.destroy(); + const sidebarDocument = this.colorPaletteEl.ownerDocument; + if (!(this.colorPaletteEl instanceof sidebarDocument.defaultView.HTMLElement)) { + // When inside an iframe, the element for mounting a component must + // be an instance of the iframe's HTMLElement, or else target + // validation for attachComponent fails. + const newEl = sidebarDocument.importNode(this.colorPaletteEl, true); + this.colorPaletteEl.before(newEl); + this.colorPaletteEl.remove(); + this.colorPaletteEl = newEl; + } + this.colorPaletteWrapper = await attachComponent(this, this.colorPaletteEl, ColorPalette, options); + }, + /** + * @override + */ + _shouldIgnoreClick(ev) { + return ev.originalEvent.__isColorpickerClick || this._super(...arguments); + }, + /** + * Browses the colorpicker XML template to return all possible values of + * [data-color]. + * + * @param {string} colorpickerTemplate + * @returns {string[]} + */ + _getColorNames(colorpickerTemplate) { + // Init with the color combinations presets as these don't appear in + // the template. + const colorNames = ["1", "2", "3", "4", "5"]; + const template = new DOMParser().parseFromString(colorpickerTemplate, "text/html"); + template.querySelectorAll("button[data-color]:not(.o_custom_gradient_btn)").forEach(el => { + const colorName = el.dataset.color; + if (!weUtils.isColorGradient(colorName)) { + colorNames.push(colorName); + } + }); + return colorNames; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when a custom color is selected -> preview the color + * and set the current value. Update of this value on close + * + * @private + * @param {Object} params + */ + _onCustomColorPicked: function (params) { + this._customColorValue = params.color; + }, + /** + * Called when a color button is clicked -> confirms the preview. + * + * @private + * @param {Object} params + */ + _onColorPicked: function (params) { + this._previewCC = false; + this._previewColor = false; + this._customColorValue = false; + + this._ccValue = params.ccValue; + this._value = params.color; + + this._onUserValueChange(); + }, + /** + * Called when a color button is entered -> previews the background color. + * + * @private + * @param {Object} params + */ + _onColorHovered: function (params) { + this._previewCC = params.ccValue; + this._previewColor = params.color; + this._onUserValuePreview(); + }, + /** + * Called when a color button is left -> cancels the preview. + * + * @private + */ + _onColorLeft: function () { + this._previewCC = false; + this._previewColor = false; + this._onUserValueReset(); + }, + /** + * @private + */ + _onEnterKey: function () { + this.close(); + }, +}); + +const MediapickerUserValueWidget = UserValueWidget.extend({ + tagName: 'we-button', + events: { + 'click': '_onEditMedia', + }, + + /** + * @override + */ + async start() { + await this._super(...arguments); + if (this.options.dataAttributes.buttonStyle) { + const iconEl = document.createElement('i'); + iconEl.classList.add('fa', 'fa-fw', 'fa-camera'); + $(this.containerEl).prepend(iconEl); + } else { + this.el.classList.add('o_we_no_toggle', 'o_we_bg_success'); + this.containerEl.textContent = _t("Replace"); + } + }, + + //-------------------------------------------------------------------------- + // 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, save}) { + el.src = this._value; + const $editable = this.$target.closest('.o_editable'); + this.call("dialog", "add", MediaDialog, { + noImages: !images, + noVideos: !videos, + noIcons: true, + noDocuments: true, + isForBgVideo: true, + vimeoPreviewIds: ['528686125', '430330731', '509869821', '397142251', '763851966', '486931161', + '499761556', '392935303', '728584384', '865314310', '511727912', '466830211'], + 'res_model': $editable.data('oe-model'), + 'res_id': $editable.data('oe-id'), + save, + media: el, + }); + }, + + //-------------------------------------------------------------------------- + // 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'); + this._openDialog(dummyEl, { + images: true, + save: (media) => { + // 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 = media.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'); + this._openDialog(dummyEl, { + videos: true, + save: (media) => { + this._value = media.querySelector('iframe').src; + this._onUserValueChange(); + }}); + }, +}); + +const DatetimePickerUserValueWidget = InputUserValueWidget.extend({ + events: { // Explicitely not consider all InputUserValueWidget events + 'blur input': '_onInputBlur', + 'input input': '_onDateInputInput', + }, + pickerType: 'datetime', + + /** + * @override + */ + init: function () { + this._super(...arguments); + this._value = DateTime.now().toUnixInteger().toString(); + }, + /** + * @override + */ + start: async function () { + await this._super(...arguments); + + this.el.classList.add('o_we_large'); + this.inputEl.classList.add('datetimepicker-input', 'mx-0', 'text-start'); + + this.picker = this.call("datetime_picker", "create", { + target: this.inputEl, + onChange: this._onDateTimePickerChange.bind(this), + pickerProps: { + type: this.pickerType, + minDate: DateTime.fromObject({ year: 1000 }), + maxDate: DateTime.now().plus({ year: 200 }), + value: DateTime.fromSeconds(parseInt(this._value)), + rounding: 0, + }, + }); + this.picker.enable(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getMethodsParams: function () { + return Object.assign(this._super(...arguments), { + format: this.defaultFormat, + }); + }, + /** + * @override + */ + isPreviewed: function () { + return this._super(...arguments) || this.picker.isOpen; + }, + /** + * @override + */ + async setValue() { + await this._super(...arguments); + let dateTime = null; + if (this._value) { + dateTime = DateTime.fromSeconds(parseInt(this._value)) + if (!dateTime.isValid) { + dateTime = DateTime.now(); + } + } + this.picker.state.value = dateTime; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onDateTimePickerChange: function (newDateTime) { + if (!newDateTime || !newDateTime.isValid) { + this._value = ''; + } else { + this._value = newDateTime.toUnixInteger().toString(); + } + this._onUserValuePreview(); + }, + /** + * Handles the clear button of the datepicker. + * + * @private + * @param {Event} ev + */ + _onDateInputInput(ev) { + if (!this.inputEl.value) { + this._value = ''; + this._onUserValuePreview(ev); + } + }, +}); + +const DatePickerUserValueWidget = DatetimePickerUserValueWidget.extend({ + pickerType: 'date', +}); + +const ListUserValueWidget = UserValueWidget.extend({ + tagName: 'we-list', + events: { + 'click we-button.o_we_select_remove_option': '_onRemoveItemClick', + 'click we-button.o_we_list_add_optional': '_onAddCustomItemClick', + 'click we-button.o_we_list_add_existing': '_onAddExistingItemClick', + 'click we-select.o_we_user_value_widget.o_we_add_list_item': '_onAddItemSelectClick', + 'click we-button.o_we_checkbox_wrapper': '_onAddItemCheckboxClick', + 'input table input': '_onListItemBlurInput', + 'blur table input': '_onListItemBlurInput', + 'mousedown': '_onWeListMousedown', + }, + + /** + * @override + */ + willStart() { + if (this.options.createWidget) { + this.createWidget = this.options.createWidget; + this.createWidget.setParent(this); + this.registerSubWidget(this.createWidget); + } + return this._super(...arguments); + }, + /** + * @override + */ + start() { + this.addItemTitle = this.el.dataset.addItemTitle || _t("Add"); + if (this.el.dataset.availableRecords) { + this.records = JSON.parse(this.el.dataset.availableRecords); + } else { + this.isCustom = !this.el.dataset.notEditable; + } + if (this.el.dataset.defaults || this.el.dataset.hasDefault) { + this.hasDefault = this.el.dataset.hasDefault || 'unique'; + this.selected = this.el.dataset.defaults ? JSON.parse(this.el.dataset.defaults) : []; + } + this.listTable = document.createElement('table'); + const tableWrapper = document.createElement('div'); + tableWrapper.classList.add('o_we_table_wrapper'); + tableWrapper.appendChild(this.listTable); + this.containerEl.appendChild(tableWrapper); + this.el.classList.add('o_we_fw'); + this._makeListItemsSortable(); + if (this.createWidget) { + return this.createWidget.appendTo(this.containerEl); + } + }, + + /** + * @override + */ + destroy() { + this.bindedSortable?.cleanup(); + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getMethodsParams() { + return Object.assign(this._super(...arguments), { + records: this.records, + }); + }, + /** + * @override + */ + setValue() { + this._super(...arguments); + const currentValues = this._value ? JSON.parse(this._value) : []; + this.listTable.innerHTML = ''; + if (this.addItemButton) { + this.addItemButton.remove(); + } + + if (this.createWidget) { + const selectedIds = currentValues.map(({ id }) => id) + .filter(id => typeof id === 'number'); + // Note: it's important to simplify the domain at its maximum as the + // rpc using it are cached. Similar domain components should be + // written the same way for the cache to work. + this.createWidget.options.domainComponents.selected = selectedIds.length ? ['id', 'not in', selectedIds] : null; + this.createWidget.setValue(''); + this.createWidget.inputEl.value = ''; + $(this.createWidget.inputEl).trigger('input'); + } else { + if (this.isCustom) { + this.addItemButton = document.createElement('we-button'); + this.addItemButton.textContent = this.addItemTitle; + this.addItemButton.classList.add('o_we_list_add_optional'); + } else { + // TODO use a real select widget ? + this.addItemButton = document.createElement('we-select'); + this.addItemButton.classList.add('o_we_user_value_widget', 'o_we_add_list_item'); + const divEl = document.createElement('div'); + this.addItemButton.appendChild(divEl); + const togglerEl = document.createElement('we-toggler'); + togglerEl.textContent = this.addItemTitle; + divEl.appendChild(togglerEl); + this.selectMenuEl = document.createElement('we-selection-items'); + divEl.appendChild(this.selectMenuEl); + } + this.containerEl.appendChild(this.addItemButton); + } + currentValues.forEach(value => { + if (typeof value === 'object') { + const recordData = value; + const { id, display_name } = recordData; + delete recordData.id; + delete recordData.display_name; + this._addItemToTable(id, display_name, recordData); + } else { + this._addItemToTable(value, value); + } + }); + if (!this.createWidget && !this.isCustom) { + this._reloadSelectDropdown(currentValues); + } + this._makeListItemsSortable(); + }, + /** + * @override + */ + getValue(methodName) { + if (this.createWidget && this.createWidget.getMethodsNames().includes(methodName)) { + return this.createWidget.getValue(methodName); + } + return this._value; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string || integer} id + * @param {string} [value] + * @param {Object} [recordData] key, values that will be added to the + * element's dataset + */ + _addItemToTable(id, value = this.el.dataset.defaultValue || _t("Item"), recordData) { + const trEl = document.createElement('tr'); + if (!this.el.dataset.unsortable) { + const draggableEl = document.createElement('we-button'); + draggableEl.classList.add('o_we_drag_handle', 'o_we_link', 'fa', 'fa-fw', 'fa-arrows'); + draggableEl.dataset.noPreview = 'true'; + const draggableTdEl = document.createElement('td'); + draggableTdEl.appendChild(draggableEl); + trEl.appendChild(draggableTdEl); + } + let recordDataSelected = false; + const inputEl = document.createElement('input'); + inputEl.type = this.el.dataset.inputType || 'text'; + if (value) { + inputEl.value = value; + } + if (id) { + inputEl.name = id; + } + if (recordData) { + recordDataSelected = recordData.selected; + if (recordData.placeholder) { + inputEl.placeholder = recordData.placeholder; + } + for (const key of Object.keys(recordData)) { + inputEl.dataset[key] = recordData[key]; + } + } + inputEl.disabled = !this.isCustom; + const inputTdEl = document.createElement('td'); + inputTdEl.classList.add('o_we_list_record_name'); + inputTdEl.appendChild(inputEl); + trEl.appendChild(inputTdEl); + if (this.hasDefault) { + const checkboxEl = document.createElement('we-button'); + checkboxEl.classList.add('o_we_user_value_widget', 'o_we_checkbox_wrapper'); + if (this.selected.includes(id) || recordDataSelected) { + checkboxEl.classList.add('active'); + } + if (!recordData || !recordData.notToggleable) { + const div = document.createElement('div'); + const checkbox = document.createElement('we-checkbox'); + div.appendChild(checkbox); + checkboxEl.appendChild(div); + checkboxEl.appendChild(checkbox); + const checkboxTdEl = document.createElement('td'); + checkboxTdEl.appendChild(checkboxEl); + trEl.appendChild(checkboxTdEl); + } + } + if (!recordData || !recordData.undeletable) { + const buttonTdEl = document.createElement('td'); + const buttonEl = document.createElement('we-button'); + buttonEl.classList.add('o_we_select_remove_option', 'o_we_link', 'o_we_text_danger', 'fa', 'fa-fw', 'fa-minus'); + buttonEl.dataset.removeOption = id; + buttonTdEl.appendChild(buttonEl); + trEl.appendChild(buttonTdEl); + } + this.listTable.appendChild(trEl); + }, + /** + * @override + */ + _getFocusableElement() { + return this.listTable.querySelector('input'); + }, + /** + * @private + */ + _makeListItemsSortable() { + if (this.el.dataset.unsortable) { + return; + } + this.bindedSortable = this.call( + "sortable", + "create", + { + ref: { el: this.listTable }, + elements: "tr", + followingElementClasses: ["opacity-50"], + handle: ".o_we_drag_handle", + onDrop: () => this._notifyCurrentState(), + applyChangeOnDrop: true, + }, + ).enable(); + }, + /** + * @private + * @param {Boolean} [preview] + */ + _notifyCurrentState(preview = false) { + const isIdModeName = this.el.dataset.idMode === "name" || !this.isCustom; + const trimmed = (str) => str.trim().replace(/\s+/g, " "); + const values = [...this.listTable.querySelectorAll('.o_we_list_record_name input')].map(el => { + const id = trimmed(isIdModeName ? el.name : el.value); + return Object.assign({ + id: /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id, + name: trimmed(el.value), + display_name: trimmed(el.value), + }, el.dataset); + }); + if (this.hasDefault) { + const checkboxes = [...this.listTable.querySelectorAll('we-button.o_we_checkbox_wrapper.active')]; + this.selected = checkboxes.map(el => { + const input = el.parentElement.previousSibling.firstChild; + const id = trimmed(isIdModeName ? input.name : input.value); + return /^-?[0-9]{1,15}$/.test(id) ? parseInt(id) : id; + }); + values.forEach(v => { + // Elements not toggleable are considered as always selected. + // We have to check that it is equal to the string 'true' + // because this information comes from the dataset. + v.selected = this.selected.includes(v.id) || v.notToggleable === 'true'; + }); + } + this._value = JSON.stringify(values); + if (preview) { + this._onUserValuePreview(); + } else { + this._onUserValueChange(); + } + if (!this.createWidget && !this.isCustom) { + this._reloadSelectDropdown(values); + } + }, + /** + * @private + * @param {Array} currentValues + */ + _reloadSelectDropdown(currentValues) { + this.selectMenuEl.innerHTML = ''; + this.records.forEach(el => { + if (!currentValues.find(v => v.id === el.id)) { + const option = document.createElement('we-button'); + option.classList.add('o_we_list_add_existing'); + option.dataset.addOption = el.id; + option.dataset.noPreview = 'true'; + const divEl = document.createElement('div'); + divEl.textContent = el.display_name; + option.appendChild(divEl); + this.selectMenuEl.appendChild(option); + } + }); + if (!this.selectMenuEl.children.length) { + const title = document.createElement('we-title'); + title.textContent = _t("No more records"); + this.selectMenuEl.appendChild(title); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAddCustomItemClick() { + const recordData = {}; + if (this.el.dataset.newElementsNotToggleable) { + recordData.notToggleable = true; + } + this._addItemToTable(undefined, this.el.dataset.defaultValue, recordData); + this._notifyCurrentState(); + // Scroll to the new list element. + this.el.querySelector('tr:last-child') + .scrollIntoView({behavior: 'smooth', block: 'nearest'}); + }, + /** + * @private + * @param {Event} ev + */ + _onAddExistingItemClick(ev) { + const value = ev.currentTarget.dataset.addOption; + this._addItemToTable(value, ev.currentTarget.textContent); + this._notifyCurrentState(); + }, + /** + * @private + * @param {Event} ev + */ + _onAddItemSelectClick(ev) { + ev.currentTarget.querySelector('we-toggler').classList.toggle('active'); + }, + /** + * @private + * @param {Event} ev + */ + _onAddItemCheckboxClick: function (ev) { + const isActive = ev.currentTarget.classList.contains('active'); + if (this.hasDefault === 'unique') { + this.listTable.querySelectorAll('we-button.o_we_checkbox_wrapper.active').forEach(el => el.classList.remove('active')); + } + ev.currentTarget.classList.toggle('active', !isActive); + this._notifyCurrentState(); + }, + /** + * @private + * @param {Event} ev + */ + _onListItemBlurInput(ev) { + const preview = ev.type === 'input'; + if (preview || !this.el.contains(ev.relatedTarget) || this.el.dataset.renderOnInputBlur) { + // We call the function below only if the element that recovers the + // focus after this blur is not an element of the we-list or if it + // is an input event (preview). This allows to use the TAB key to go + // from one input to another in the list. This behavior can be + // cancelled if the widget has reloadOnInputBlur = "true" in its + // dataset. + const timeSinceMousedown = ev.timeStamp - this.mousedownTime; + if (timeSinceMousedown < 500) { + // Without this "setTimeOut", "click" events are not triggered when + // clicking directly on a "we-button" of the "we-list" without first + // focusing out the input. + setTimeout(() => { + this._notifyCurrentState(preview); + }, 500); + } else { + this._notifyCurrentState(preview); + } + } + }, + /** + * @private + */ + _onWeListMousedown(ev) { + this.mousedownTime = ev.timeStamp; + }, + /** + * @private + * @param {Event} ev + */ + _onRemoveItemClick(ev) { + const minElements = this.el.dataset.allowEmpty ? 0 : 1; + if (ev.target.closest('table').querySelectorAll('tr').length > minElements) { + ev.target.closest('tr').remove(); + this._notifyCurrentState(); + } + }, + /** + * @override + */ + _onUserValueNotification(ev) { + const { widget, previewMode, prepare } = ev.data; + if (widget && widget === this.createWidget) { + if (widget.options.createMethod && widget.getValue(widget.options.createMethod)) { + return this._super(ev); + } + ev.stopPropagation(); + if (previewMode) { + return; + } + prepare(); + const recordData = JSON.parse(widget.getMethodsParams('addRecord').recordData); + const { id, display_name } = recordData; + delete recordData.id; + delete recordData.display_name; + this._addItemToTable(id, display_name, recordData); + this._notifyCurrentState(); + } + return this._super(ev); + }, +}); + +const RangeUserValueWidget = UnitUserValueWidget.extend({ + tagName: 'we-range', + events: { + 'change input': '_onInputChange', + 'input input': '_onInputInput', + }, + + /** + * @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; + this.displayValue = this.el.dataset.displayRangeValue; + if (min > max) { + [min, max] = [max, min]; + this.input.classList.add('o_we_inverted_range'); + } + this._setInputAttributes(min, max, step); + this.containerEl.appendChild(this.input); + if (this.displayValue) { + this.outputEl = document.createElement('output'); + this.outputEl.classList.add('ms-2'); + this.containerEl.appendChild(this.outputEl); + } + + this._onInputChange = debounce(this._onInputChange, 100); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + loadMethodsData(validMethodNames) { + this._super(...arguments); + for (const methodName of this._methodsNames) { + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + if (possibleValues.length > 1) { + this._setInputAttributes(0, possibleValues.length - 1, 1); + break; + } + } + }, + /** + * @override + */ + async setValue(value, methodName) { + await this._super(...arguments); + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + const inputValue = possibleValues.length > 1 ? possibleValues.indexOf(value) : this._value; + this.input.value = inputValue; + if (this.displayValue) { + this._computeDisplayValue(inputValue); + } + }, + /** + * @override + */ + getValue(methodName) { + const value = this._super(...arguments); + const possibleValues = this._methodsParams.optionsPossibleValues[methodName]; + return possibleValues.length > 1 ? possibleValues[+value] : value; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onInputChange(ev) { + this._value = ev.target.value; + this._onUserValueChange(ev); + }, + /** + * @private + * @param {string} inputValue + */ + _computeDisplayValue(inputValue) { + if (this.el.dataset.toRatio) { + const inputValueAsNumber = Number(inputValue); + const ratio = inputValueAsNumber >= 0 ? 1 + inputValueAsNumber : 1 / (1 - inputValueAsNumber); + this.outputEl.value = `${ratio.toFixed(2)}x`; + } else { + this.outputEl.value = inputValue; + } + }, + /** + * @private + * @param {Event} ev + */ + _onInputInput(ev) { + this._value = ev.target.value; + if (this.displayValue) { + this._computeDisplayValue(this._value); + } + this._onUserValuePreview(ev); + }, + /** + * @private + */ + _setInputAttributes(min, max, step) { + this.input.setAttribute('min', min); + this.input.setAttribute('max', max); + this.input.setAttribute('step', step); + }, +}); + +const SelectPagerUserValueWidget = SelectUserValueWidget.extend({ + className: (SelectUserValueWidget.prototype.className || '') + ' o_we_select_pager', + events: Object.assign({}, SelectUserValueWidget.prototype.events, { + 'click .o_pager_nav_btn': '_onClickScrollPage', + 'click .o_pager_nav_angle': '_onClickCloseMenu', + }), + /** + * @override + */ + async start() { + const _super = this._super.bind(this); + + await _super(...arguments); + this.menuEl.classList.add('o_we_has_pager', 'position-fixed', 'top-0', 'end-0', 'z-1', 'rounded-0'); + this.menuTogglerEl.classList.add('o_we_toggler_pager'); + + this.pagerContainerEl = this.el.querySelector('.o_pager_container'); + this.__onScroll = throttleForAnimation(this._onScroll.bind(this)); + this.pagerContainerEl.addEventListener('scroll', this.__onScroll); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this.pagerContainerEl.removeEventListener('scroll', this.__onScroll); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * We never try to adjust the position for selection with pagers as they + * are fullscreen. + * + * @override + */ + _adjustDropdownPosition() { + return; + }, + /** + * @override + */ + _shouldIgnoreClick(ev) { + return !!ev.target.closest('.o_pager_nav') || this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Scrolls to the requested section. + * + * @private + */ + _onClickScrollPage(ev) { + const navButtonEl = ev.currentTarget; + const attribute = navButtonEl.dataset.scrollTo; + const destinationOffset = this.menuEl.querySelector('.' + attribute).offsetTop; + + const pagerNavEl = this.menuEl.querySelector('.o_pager_nav'); + this.pagerContainerEl.scrollTop = destinationOffset - pagerNavEl.offsetHeight; + }, + /** + * @private + */ + _onClickCloseMenu(ev) { + this.close(); + }, + /** + * @private + */ + _onScroll(ev) { + const pagerContainerHeight = this.pagerContainerEl.getBoundingClientRect().height; + // The threshold for when a menu element is defined as 'active' is half + // of the container's height. This has a drawback as if a section + // is too small it might never get `active` if it's the last section. + const threshold = this.pagerContainerEl.scrollTop + (pagerContainerHeight / 2); + const anchorElements = this.menuEl.querySelectorAll('[data-scroll-to]'); + for (const anchorEl of anchorElements) { + const destination = anchorEl.getAttribute('data-scroll-to'); + const sectionEl = this.menuEl.querySelector(`.${destination}`); + const nextSectionEl = sectionEl.nextElementSibling; + anchorEl.classList.toggle('active', sectionEl.offsetTop < threshold && + (!nextSectionEl || nextSectionEl.offsetTop > threshold)); + } + } +}); + +const Many2oneUserValueWidget = SelectUserValueWidget.extend({ + className: (SelectUserValueWidget.prototype.className || '') + ' o_we_many2one', + events: Object.assign({}, SelectUserValueWidget.prototype.events, { + 'input .o_we_m2o_search input': '_onSearchInput', + 'keydown .o_we_m2o_search input': '_onSearchKeydown', + 'click .o_we_m2o_search_more': '_onSearchMoreClick', + }), + // Data-attributes that will be read into `this.options` on init and not + // transfered to inner buttons. + // `domain` is the static part of the domain used in searches, not + // depending on already selected ids and other filters. + configAttributes: [ + "model", "fields", "limit", "domain", + "callWith", "createMethod", "filterInModel", "filterInField", "nullText", + "defaultMessage", + ], + + /** + * @override + */ + init(parent, title, options, $target) { + this.afterSearch = []; + this.displayNameCache = {}; + const {dataAttributes} = options; + Object.assign(options, { + limit: '5', + fields: '[]', + domain: '[]', + callWith: 'id', + }); + this.configAttributes.forEach(attr => { + if (dataAttributes.hasOwnProperty(attr)) { + options[attr] = dataAttributes[attr]; + delete dataAttributes[attr]; + } + }); + options.limit = parseInt(options.limit); + options.fields = JSON.parse(options.fields); + if (!options.fields.includes('display_name')) { + options.fields.push('display_name'); + } + options.domain = JSON.parse(options.domain); + options.domainComponents = {}; + options.nullText = $target[0].dataset.nullText || + JSON.parse($target[0].dataset.oeContactOptions || '{}')['null_text']; + + this.orm = serviceCached(this.bindService("orm")); + + return this._super(...arguments); + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + + this.inputEl = document.createElement('input'); + this.inputEl.setAttribute('placeholder', _t("Search for records...")); + const searchEl = document.createElement('div'); + searchEl.classList.add('o_we_m2o_search'); + searchEl.appendChild(this.inputEl); + this.menuEl.appendChild(searchEl); + + this.searchMore = document.createElement('div'); + this.searchMore.classList.add('o_we_m2o_search_more'); + this.searchMore.textContent = _t("Search more..."); + this.searchMore.title = _t("Search to show more records"); + + if (this.options.createMethod) { + this.createInput = new InputUserValueWidget(this, undefined, { + classes: ['o_we_large'], + dataAttributes: { noPreview: 'true' }, + }, this.$target); + this.createButton = new ButtonUserValueWidget(this, undefined, { + classes: ['flex-grow-0'], + dataAttributes: { + noPreview: 'true', + [this.options.createMethod]: '', // Value through getValue. + }, + childNodes: [document.createTextNode(_t("Create"))], + }, this.$target); + // Override isActive so it doesn't show up in toggler + this.createButton.isActive = () => false; + + await Promise.all([ + this.createInput.appendTo(document.createDocumentFragment()), + this.createButton.appendTo(document.createDocumentFragment()), + ]); + this.registerSubWidget(this.createInput); + this.registerSubWidget(this.createButton); + this.createWidget = _buildRowElement('', { + classes: ['o_we_full_row', 'o_we_m2o_create', 'p-1'], + childNodes: [this.createInput.el, this.createButton.el], + }); + } + + return this._search(''); + }, + /** + * @override + */ + async setValue(value, methodName) { + await this._super(...arguments); + if (this.menuTogglerEl.textContent === this.PLACEHOLDER_TEXT.toString()) { + // The currently selected value is not present in the search, need to read + // its display name. + if (value !== '') { + // FIXME: value may not be an id if callWith is specified! + this.menuTogglerEl.textContent = await this._getDisplayName(parseInt(value)); + } else { + this.menuTogglerEl.textContent = this.options.defaultMessage || _t("Choose a record..."); + } + } + }, + /** + * @override + */ + getValue(methodName) { + if (methodName === this.options.createMethod && this.createInput) { + return this.createInput._value; + } + return this._super(...arguments); + }, + /** + * Prevents double widget instanciation for we-buttons that have been + * created manually by _search (container widgets will have their innner + * html searched for userValueWidgets to instanciate during option startup) + * + * @override + */ + isContainer() { + return false; + }, + /** + * @override + */ + open() { + if (this.createInput) { + this.createInput.setValue(''); + } + return this._super(...arguments); + }, + /** + * Updates the domain with defined inclusive filter to make sure that only + * records that are linked to specific records are retrieved. + * Filtering-in is configured with + * * a `filterInModel` attribute, the linked model + * * a `filterInField` attribute, field of the linked model holding + * allowed values for this widget + * + * @param {integer[]} linkedRecordsIds + * @returns {Promise} + */ + async setFilterInDomainIds(linkedRecordsIds) { + const allowedIds = new Set(); + if (linkedRecordsIds) { + const parentRecordsData = await this.orm.searchRead( + this.options.filterInModel, + [['id', 'in', linkedRecordsIds]], + [this.options.filterInField] + ); + parentRecordsData.forEach(record => { + record[this.options.filterInField].forEach(item => allowedIds.add(item)); + }); + } + if (allowedIds.size) { + this.options.domainComponents.filterInModel = ['id', 'in', [...allowedIds]]; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Searches the database for corresponding records and updates the dropdown + * + * @private + */ + async _search(needle) { + const recTuples = await this.orm.call(this.options.model, "name_search", [], { + name: needle, + args: (await this._getSearchDomain()).concat( + Object.values(this.options.domainComponents).filter(item => item !== null) + ), + operator: "ilike", + limit: this.options.limit + 1, + }); + const records = await this.orm.read( + this.options.model, + recTuples.map(([id, _name]) => id), + this.options.fields + ); + // Remove select options. + this._userValueWidgets.filter(widget => { + return widget instanceof ButtonUserValueWidget && + !widget.isDestroyed() && + widget.el.parentElement.matches('we-selection-items'); + }).forEach(button => { + if (button.isPreviewed()) { + button.notifyValueChange('reset'); + } + button.destroy(); + }); + this._userValueWidgets = this._userValueWidgets.filter(widget => !widget.isDestroyed()); + if (this.options.nullText && + this.options.nullText.toLowerCase().includes(needle.toLowerCase())) { + // Beware of RPC cache. + if (!records.length || records[0].id) { + records.unshift({id: 0, name: this.options.nullText, display_name: this.options.nullText}); + } + } + records.forEach(record => { + this.displayNameCache[record.id] = record.display_name; + }); + + await Promise.all(records.slice(0, this.options.limit).map(async record => { + // Copy over the data-attributes from the main element, and default the value + // to the callWith field of the record so that if it's a method, it will + // be called with that value + const buttonDataAttributes = Object.assign({}, this.options.dataAttributes); + Object.keys(buttonDataAttributes).forEach(key => { + buttonDataAttributes[key] = buttonDataAttributes[key] || record[this.options.callWith]; + }); + // REMARK: this syntax is very similar to React.createComponent, maybe we could + // write a transformer like there is for JSX? + const buttonWidget = new ButtonUserValueWidget(this, undefined, { + dataAttributes: Object.assign({recordData: JSON.stringify(record)}, buttonDataAttributes), + childNodes: [document.createTextNode(record.display_name)], + }, this.$target); + this.registerSubWidget(buttonWidget); + await buttonWidget.appendTo(this.menuEl); + if (this._methodsNames) { + buttonWidget.loadMethodsData(this._methodsNames); + } + })); + // Load methodsData for new buttons if possible. It will not be possible + // when the widget is first created (as this._methodsNames will be undefined) + // but the snippetOption lifecycle will load the methods data explicitely + // just after creating the widget + if (this._methodsNames) { + this._methodsNames.forEach(methodName => { + this.setValue(this._value, methodName); + }); + } + + const hasMore = records.length > this.options.limit; + if (hasMore) { + this.menuEl.appendChild(this.searchMore); + this.searchMore.classList.remove('d-none'); + } else { + this.searchMore.classList.add('d-none'); + } + + if (this.createWidget) { + this.menuEl.appendChild(this.createWidget); + } + + this.waitingForSearch = false; + this.afterSearch.forEach(cb => cb()); + this.afterSearch = []; + if (this.options.nullText && !this.getValue()) { + this.setValue(0); + } + }, + /** + * Returns the domain to use for the search. + * + * @private + */ + async _getSearchDomain() { + return this.options.domain; + }, + /** + * Returns the display name for a given record. + * + * @private + */ + async _getDisplayName(recordId) { + if (!this.displayNameCache.hasOwnProperty(recordId)) { + this.displayNameCache[recordId] = (await this.orm.read(this.options.model, [recordId], ['display_name']))[0].display_name; + } + return this.displayNameCache[recordId]; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onClick(ev) { + // Prevent dropdown from closing if you click on the search or has_more + if (ev.target.closest('.o_we_m2o_search_more, .o_we_m2o_search, .o_we_m2o_create') && + !ev.target.closest('we-button')) { + ev.stopPropagation(); + return; + } + return this._super(...arguments); + }, + /** + * Handles changes to the search bar. + * + * @private + */ + _onSearchInput(ev) { + // maybe there is a concurrency primitive we can use instead of manual record-keeping? + // Basically we want to queue the enter action to go after the current search if there + // is one that is ongoing (ie currently waiting for the debounce or RPC) + clearTimeout(this.searchIntent); + this.waitingForSearch = true; + this.searchIntent = setTimeout(() => { + this._search(ev.target.value); + }, 500); + }, + /** + * Selects the first option when pressing enter in the search input. + * + * @private + */ + _onSearchKeydown(ev) { + if (ev.key !== "Enter") { + return; + } + const action = () => { + const firstButton = this.menuEl.querySelector(':scope > we-button'); + if (firstButton) { + firstButton.click(); + } + }; + if (this.waitingForSearch) { + this.afterSearch.push(action); + } else { + action(); + } + }, + /** + * Focuses the search input when clicking on the "Search more..." button. + * + * @private + */ + _onSearchMoreClick(ev) { + this.inputEl.focus(); + }, + /** + * @override + */ + _onUserValueNotification(ev) { + const { widget } = ev.data; + if (widget && widget === this.createInput) { + ev.stopPropagation(); + return; + } + if (widget && widget === this.createButton) { + // When the create button is clicked, make sure the text + // value is restored from the actual input element because + // it might have been removed when hovering existing tags. + // TODO review this, there is probably better to do + this.createInput._value = this.createInput.el.querySelector('input').value; + if (!this.createInput._value) { + ev.stopPropagation(); + } + return; + } + if (widget !== this.createButton && this.createInput) { + this.createInput._value = ''; + } + return this._super(ev); + }, +}); + +const Many2manyUserValueWidget = UserValueWidget.extend({ + configAttributes: ['model', 'recordId', 'm2oField', 'createMethod', 'fakem2m', 'filterIn'], + + /** + * @override + */ + init(parent, title, options, $target) { + const { dataAttributes } = options; + this.configAttributes.forEach(attr => { + if (dataAttributes.hasOwnProperty(attr)) { + options[attr] = dataAttributes[attr]; + delete dataAttributes[attr]; + } + }); + this.filterIn = options.filterIn !== undefined; + if (this.filterIn) { + // Transfer filter-in values to child m2o. + dataAttributes.filterInModel = options.model; + dataAttributes.filterInField = options.m2oField; + } + this.orm = this.bindService("orm"); + this.fields = this.bindService("field"); + return this._super(...arguments); + }, + /** + * @override + */ + async willStart() { + await this._super(...arguments); + // If the widget does not have a real m2m field in the database + // We do not need to fetch anything from the DB + if (this.options.fakem2m) { + this.m2oModel = this.options.model; + return; + } + const { model, recordId, m2oField } = this.options; + const [record] = await this.orm.read(model, [parseInt(recordId)], [m2oField]); + const selectedRecordIds = record[m2oField]; + // TODO: handle no record + const modelData = await this.fields.loadFields(model, { fieldNames: [m2oField] }); + // TODO: simultaneously fly both RPCs + this.m2oModel = modelData[m2oField].relation; + this.m2oName = modelData[m2oField].field_description; // Use as string attr? + + const selectedRecords = await this.orm.read(this.m2oModel, selectedRecordIds, ['display_name']); + // TODO: reconcile the fact that this widget sets its own initial value + // instead of it coming through setValue(_computeWidgetState) + this._value = JSON.stringify(selectedRecords); + }, + /** + * @override + */ + async start() { + this.el.classList.add('o_we_m2m'); + const m2oDataAttributes = Object.entries(this.options.dataAttributes).filter(([attrName]) => { + return Many2oneUserValueWidget.prototype.configAttributes.includes(attrName); + }); + m2oDataAttributes.push( + ['model', this.m2oModel], + ['addRecord', ''], + ['createMethod', this.options.createMethod], + ); + // Don't register this one as a subWidget because it will be a subWidget + // of the listWidget + this.createWidget = new Many2oneUserValueWidget(null, undefined, { + dataAttributes: Object.fromEntries(m2oDataAttributes), + }, this.$target); + + this.listWidget = registerUserValueWidget('we-list', this, undefined, { + dataAttributes: { unsortable: 'true', notEditable: 'true', allowEmpty: 'true' }, + createWidget: this.createWidget, + }, this.$target); + await this.listWidget.appendTo(this.containerEl); + + // Make this.el the select's offsetParent so the we-selection-items has + // the correct width + this.listWidget.el.querySelector('we-select').style.position = 'static'; + this.el.style.position = 'relative'; + }, + /** + * Only allow to fetch/select records which are linked (via `m2oField`) to the + * specified records. + * + * @param {integer[]} linkedRecordsIds + * @returns {Promise} + * @see Many2oneUserValueWidget.setFilterInDomainIds + */ + async setFilterInDomainIds(linkedRecordsIds) { + if (this.filterIn) { + return this.listWidget.createWidget.setFilterInDomainIds(linkedRecordsIds); + } + }, + /** + * @override + */ + loadMethodsData(validMethodNames, ...rest) { + // TODO: check that addRecord is still needed. + this._super(['addRecord', ...validMethodNames], ...rest); + this._methodsNames = this._methodsNames.filter(name => name !== 'addRecord'); + }, + /** + * @override + */ + setValue(value, methodName) { + if (methodName === this.options.createMethod) { + return this.createWidget.setValue(value, methodName); + } + if (!value) { + // TODO: why do we need this. + value = this._value; + } + this._super(value, methodName); + this.listWidget.setValue(this._value); + }, + /** + * @override + */ + getValue(methodName) { + return this.listWidget.getValue(methodName); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onUserValueNotification(ev) { + const { widget, previewMode } = ev.data; + if (!widget) { + return this._super(ev); + } + if (widget === this.listWidget) { + ev.stopPropagation(); + this._value = widget._value; + this.notifyValueChange(previewMode); + } + }, +}); + +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-datepicker': DatePickerUserValueWidget, + 'we-list': ListUserValueWidget, + 'we-imagepicker': ImagepickerUserValueWidget, + 'we-videopicker': VideopickerUserValueWidget, + 'we-range': RangeUserValueWidget, + 'we-select-pager': SelectPagerUserValueWidget, + 'we-many2one': Many2oneUserValueWidget, + 'we-many2many': Many2manyUserValueWidget, +}; + +/** + * 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, + /** + * Indicates if the option should be the first one displayed in the button + * group at the top of the options panel, next to the clone/remove button. + * + * @type {boolean} + */ + isTopFirstOption: false, + /** + * Forces the target to not be possible to remove. It will also hide the + * clone button. + * + * @type {boolean} + */ + forceNoDeleteButton: false, + /** + * The option needs the handles overlay to be displayed on the snippet. + * + * @type {boolean} + */ + displayOverlayOptions: false, + /** + * Forces the target to be duplicable. + * + * @type {boolean} + */ + forceDuplicateButton: 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(); + + this.dialog = this.bindService("dialog"); + }, + /** + * @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 + * @returns {Promise|undefined} + */ + async onFocus() {}, + /** + * 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 + * @param {Object} options + * @param {boolean} options.isCurrent + * true if the main element has been built (so not when a child of + * the main element has been built). + * @returns {Promise|undefined} + */ + async onBuilt(options) {}, + /** + * Called when the parent edition overlay is removed from the associated + * snippet (another snippet enters edition for example). + * + * @abstract + * @returns {Promise|undefined} + */ + async onBlur() {}, + /** + * 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 + * @returns {Promise|undefined} + */ + onRemove: async 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 () {}, + /** + * Called when the associated snippet UI needs to be cleaned (e.g. from + * visual effects like previews). + * TODO this function will replace `cleanForSave` in the future. + * + * @abstract + * @return {Promise|undefined} + */ + cleanUI: async function () {}, + /** + * Adds the given widget to the known list of user value widgets + * + * @param {UserValueWidget} widget + */ + registerSubWidget(widget) { + this._userValueWidgets.push(widget); + }, + + //-------------------------------------------------------------------------- + // 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); + if (value) { + this.$target[0].setAttribute(params.attributeName, value); + } else { + this.$target[0].removeAttribute(params.attributeName); + } + }, + /** + * Default option method which allows to select a value and set it on the + * associated snippet as a property. The name of the property is + * given by the propertyName parameter. + * + * @param {boolean} previewMode - @see this.selectClass + * @param {string} widgetValue + * @param {Object} params + */ + selectProperty: function (previewMode, widgetValue, params) { + if (!params.propertyName) { + throw new Error('Property name missing'); + } + const value = this._selectValueHelper(widgetValue, params); + this.$target[0][params.propertyName] = 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 + * @param {string} [params.forceStyle] if undefined, the method will not + * set the inline style (and thus even remove it) if the item would + * already have the given style without it (thanks to a CSS rule for + * example). If defined (as a string), it acts as the "priority" param + * of @see CSSStyleDeclaration.setProperty: it should be 'important' to + * set the style as important or '' otherwise. Note that if forceStyle + * is undefined, the style is set as important only if required to have + * an effect. + * @returns {Promise|undefined} + */ + selectStyle: async 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); + } + + // Always reset the inline style first to not put inline style on an + // element which already have this style through css stylesheets. + let cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; + for (const cssProp of cssProps) { + this.$target[0].style.setProperty(cssProp, ''); + } + if (params.extraClass) { + this.$target.removeClass(params.extraClass); + } + // Plain color and gradient are mutually exclusive as background so in + // case we edit a background-color we also have to reset the gradient + // part of the background-image property (the opposite is handled by the + // fact that editing a gradient as background is done by calling this + // method with background-color as property too, so it is automatically + // reset anyway). + let bgImageParts = undefined; + if (params.withGradients && params.cssProperty === 'background-color') { + const styles = getComputedStyle(this.$target[0]); + bgImageParts = backgroundImageCssToParts(styles['background-image']); + delete bgImageParts.gradient; + this.$target[0].style.setProperty('background-image', ''); + if (!widgetValue || widgetValue === 'false') { + // If no background-color is being set and there is an image, + // combine it with the current color combination's gradient. + const styleBgImageParts = backgroundImageCssToParts(styles['background-image']); + bgImageParts.gradient = styleBgImageParts.gradient; + } + const combined = backgroundImagePartsToCss(bgImageParts); + applyCSS.call(this, 'background-image', combined, styles); + } + + // 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). + // Note: color combinations classes are handled by a dedicated method, + // as they can be combined with normal classes. + if (params.colorNames && params.colorPrefix) { + const colorNames = params.colorNames.filter(name => !weUtils.isColorCombinationName(name)); + const classes = weUtils.computeColorClasses(colorNames, params.colorPrefix); + this.$target[0].classList.remove(...classes); + + if (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); + } + } + + const styles = window.getComputedStyle(this.$target[0]); + + // 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})`; + } + + // In case of background-color edition, we could receive a gradient, in + // which case the value has to be combined with the potential background + // image (real image). + if (params.withGradients && params.cssProperty === 'background-color' && weUtils.isColorGradient(widgetValue)) { + cssProps = ['background-image']; + bgImageParts.gradient = widgetValue; + widgetValue = backgroundImagePartsToCss(bgImageParts); + + // Also force the background-color to transparent as otherwise it + // won't act as a "gradient replacing the color combination + // background" but be applied over it (which would be the opposite + // of what happens when editing the background color). + applyCSS.call(this, 'background-color', 'rgba(0, 0, 0, 0)', styles); + } + + // 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]); + } + } + } + + let hasUserValue = false; + const applyAllCSS = (values) => { + 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; + } + + applyAllCSS([...values]); + + function applyCSS(cssProp, cssValue, styles) { + if (typeof params.forceStyle !== 'undefined') { + this.$target[0].style.setProperty(cssProp, cssValue, params.forceStyle); + return true; + } + + if (!weUtils.areCssValuesEqual(styles.getPropertyValue(cssProp), cssValue, cssProp, this.$target[0])) { + this.$target[0].style.setProperty(cssProp, cssValue); + // If change had no effect then make it important. + if (!params.preventImportant && !weUtils.areCssValuesEqual( + styles.getPropertyValue(cssProp), cssValue, cssProp, this.$target[0])) { + this.$target[0].style.setProperty(cssProp, cssValue, 'important'); + } + return true; + } + return false; + } + + if (params.extraClass) { + this.$target.toggleClass(params.extraClass, hasUserValue); + if (hasUserValue) { + // Might have changed because of the class. + for (const cssProp of cssProps) { + this.$target[0].style.removeProperty(cssProp); + } + applyAllCSS(values); + } + } + + _restoreTransitions(); + }, + /** + * Sets a color combination. + * + * @see this.selectClass for parameters + */ + async selectColorCombination(previewMode, widgetValue, params) { + if (params.colorNames) { + const names = params.colorNames.filter(weUtils.isColorCombinationName); + const classes = weUtils.computeColorClasses(names); + this.$target[0].classList.remove(...classes); + + if (widgetValue) { + this.$target[0].classList.add('o_cc', `o_cc${widgetValue}`); + } + } + }, + + //-------------------------------------------------------------------------- + // 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 + */ + notify: function (name, data) { + // We prefer to avoid refactoring this notify mechanism to make it + // asynchronous because the upcoming conversion to owl might remove it. + 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 + * @param {boolean} [assetsChanged=false] + * If true, widgets might prefer to _rerenderXML instead of calling + * this super implementation + * @returns {Promise} + */ + async updateUI({noVisibility, assetsChanged} = {}) { + // 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(); + + if (dependencies.length === 1 && dependencies[0] === 'fake') { + widget.toggleVisibility(false); + return; + } + + const dependenciesData = []; + dependencies.forEach(depName => { + const toBeActive = (depName[0] !== '!'); + if (!toBeActive) { + depName = depName.substr(1); + } + + const widget = this._requestUserValueWidgets(depName, true)[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')) { + const $userValueWidget = $(el).find('> div > .o_we_user_value_widget'); + el.classList.toggle('d-none', $userValueWidget.length && !$userValueWidget.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); + } + + return !this.displayOverlayOptions && showUI; + }, + + //-------------------------------------------------------------------------- + // 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 = ''; + for (const classNames of params.possibleValues) { + if (!classNames) { + continue; + } + const classes = classNames.split(/\s+/g); + if (params.stateToFirstClass) { + if (this.$target[0].classList.contains(classes[0])) { + return classNames; + } else { + continue; + } + } + + 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': { + let usedCC = undefined; + 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)) { + if (weUtils.isColorCombinationName(c)) { + usedCC = c; + continue; + } + 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 styles = window.getComputedStyle(this.$target[0]); + const cssProps = weUtils.CSS_SHORTHANDS[params.cssProperty] || [params.cssProperty]; + const borderWidthCssProps = weUtils.CSS_SHORTHANDS['border-width']; + const cssValues = cssProps.map(cssProp => { + let value = styles.getPropertyValue(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' : ''}`; + } + if (borderWidthCssProps.includes(cssProp) && value.endsWith('px')) { + // Rounding value up avoids zoom-in issues. + // Zoom-out issues are not an expected use case. + value = `${Math.ceil(parseFloat(value))}px`; + } + 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(); + } + + let value = cssValues.join(' '); + if (params.withGradients && params.cssProperty === 'background-color') { + // Check if there is a gradient, in that case this is the + // value to be returned, we normally do not allow color and + // gradient at the same time (the option would remove one + // if editing the other). + const parts = backgroundImageCssToParts(styles['background-image']); + if (parts.gradient) { + value = parts.gradient; + } + } + + this.$target[0].classList.remove('o_we_force_no_transition'); + + if (params.cssProperty === 'background-color' && params.withCombinations) { + if (usedCC) { + const ccValue = weUtils.getCSSVariableValue(`o-cc${usedCC}-bg-gradient`).trim().replaceAll("'", '') + || weUtils.getCSSVariableValue(`o-cc${usedCC}-bg`).trim(); + if (weUtils.areCssValuesEqual(value, ccValue)) { + // Prevent to consider that a color is used as CC + // override in case that color is the same as the + // one used in that CC. + return ''; + } + } else { + const rgba = convertCSSColorToRgba(value); + if (rgba && rgba.opacity < 0.001) { + // Prevent to consider a transparent color is + // applied as background unless it is to override a + // CC. Simply allows to add a CC on a transparent + // snippet in the first place. + return ''; + } + } + } + // When the default color is the target's "currentColor", the + // value should be handled correctly by the option. + if (value === "currentColor") { + return styles.color; + } + + return value; + } + case 'selectColorCombination': { + if (params.colorNames) { + for (const c of params.colorNames) { + if (!weUtils.isColorCombinationName(c)) { + continue; + } + const className = weUtils.computeColorClasses([c])[0]; + if (this.$target[0].classList.contains(className)) { + return c; + } + } + } + return ''; + } + } + }, + /** + * @private + * @param {string} widgetName + * @param {Object} params + * @returns {Promise|boolean} + */ + _computeWidgetVisibility: async function (widgetName, params) { + 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 = normalizeCSSColor(value); // If is a css color, normalize it + return value; + }, + /** + * @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 = registerUserValueWidget(widgetName, parentWidget || this, infos.title, infos.options, this.$target); + 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() && !widget.isDestroyed()) { + return this._renderXMLWidgets(widget.el, widget); + } + }); + }); + return Promise.all(proms); + }, + /** + * @private + * @param {...string} widgetNames + * @param {boolean} [allowParentOption=false] + * @returns {UserValueWidget[]} + */ + _requestUserValueWidgets: function (...args) { + const widgetNames = args; + let allowParentOption = false; + const lastArg = args[args.length - 1]; + if (typeof lastArg === 'boolean') { + widgetNames.pop(); + allowParentOption = lastArg; + } + + const widgets = []; + for (const widgetName of widgetNames) { + let widget = null; + this.trigger_up('user_value_widget_request', { + name: widgetName, + onSuccess: _widget => widget = _widget, + allowParentOption: allowParentOption, + }); + 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; + + if (previewMode === true) { + this.options.wysiwyg.odooEditor.automaticStepUnactive('preview_option'); + } + + // 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 = Array.from($applyTo).map((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); + } + } + + if (previewMode === 'reset' || previewMode === false) { + this.options.wysiwyg.odooEditor.automaticStepActive('preview_option'); + } + + // 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 + * + * @see this._selectValueHelper for parameters + */ + _selectAttributeHelper(value, params) { + if (!params.attributeName) { + throw new Error('Attribute name missing'); + } + return this._selectValueHelper(value, params); + }, + /** + * Used to handle value of a select + * + * @param {string} value + * @param {Object} params + * @returns {string|undefined} + */ + _selectValueHelper(value, params) { + 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('we-toggler.o_we_collapse_toggler').classList.toggle('active', show); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onCollapseTogglerClick(ev) { + const currentCollapseEl = ev.currentTarget.closest('we-collapse'); + 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 => { + this.dialog.add(ConfirmationDialog, { + body: warnMessage, + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + if (!okWarning) { + return; + } + } + + requiresReload = !!await this._checkIfWidgetsUpdateNeedReload(widgets); + } + + // 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); + if (shouldRecordUndo) { + this.options.wysiwyg.odooEditor.unbreakableStepUnactive(); + } + const useLoaderOnOptionPanel = ev.target.el.dataset.loaderOnOptionPanel; + 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; + } + + // Call widget option methods and update $target + await this._select(previewMode, widget); + + // If it is not preview mode, the user selected the option for good + // (so record the action) + if (shouldRecordUndo) { + this.options.wysiwyg.odooEditor.historyStep(); + } + + if (previewMode || requiresReload) { + 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. + })); + }, optionsLoader: useLoaderOnOptionPanel}); + + 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 + // It is possible that we don't have the widget, we continue because a + // reload might be needed. For example, change template header without + // being on a website.page (e.g: /shop). + const linkedWidgets = this._requestUserValueWidgets(...ev.data.triggerWidgetsNames); + 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, + optionSelector: this.data.selector, + url: this.data.reload, + }); + } + }, + /** + * @private + */ + _onUserValueWidgetCritical() { + this.trigger_up('remove_snippet', { + $snippet: this.$target, + }); + }, +}); +const registry = {}; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +registry.sizing = SnippetOptionWidget.extend({ + displayOverlayOptions: true, + + /** + * @override + */ + start: function () { + const self = this; + const def = this._super.apply(this, arguments); + let isMobile = weUtils.isMobileView(this.$target[0]); + + this.$handles = this.$overlay.find('.o_handle'); + + let resizeValues = this._getSize(); + this.$handles.on('mousedown', function (ev) { + const mousedownTime = ev.timeStamp; + ev.preventDefault(); + isMobile = weUtils.isMobileView(self.$target[0]); + + // First update size values as some element sizes may not have been + // initialized on option start (hidden slides, etc) + resizeValues = self._getSize(); + const $handle = $(ev.currentTarget); + + let compass = false; + let 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'; + } else if ($handle.hasClass('nw')) { + compass = 'nw'; + XY = 'YX'; + } else if ($handle.hasClass('ne')) { + compass = 'ne'; + XY = 'YX'; + } else if ($handle.hasClass('sw')) { + compass = 'sw'; + XY = 'YX'; + } else if ($handle.hasClass('se')) { + compass = 'se'; + XY = 'YX'; + } + + // Don't call the normal resize methods if we are in a grid and + // vice-versa. + const isGrid = Object.keys(resizeValues).length === 4; + const isGridHandle = $handle[0].classList.contains('o_grid_handle'); + if (isGrid && !isGridHandle || !isGrid && isGridHandle) { + return; + } + + let resizeVal; + if (compass.length > 1) { + resizeVal = [resizeValues[compass[0]], resizeValues[compass[1]]]; + } else { + resizeVal = [resizeValues[compass]]; + } + + if (resizeVal.some(rV => !rV)) { + return; + } + + // Locking the mutex during the resize. Started here to avoid + // empty returns. + let resizeResolve; + const prom = new Promise(resolve => resizeResolve = () => resolve()); + self.trigger_up("snippet_edition_request", { exec: () => { + self.trigger_up("disable_loading_effect"); + return prom; + }}); + + // If we are in grid mode, add a background grid and place it in + // front of the other elements. + const rowEl = self.$target[0].parentNode; + let backgroundGridEl; + if (rowEl.classList.contains("o_grid_mode") && !isMobile) { + self.options.wysiwyg.odooEditor.observerUnactive('displayBackgroundGrid'); + backgroundGridEl = gridUtils._addBackgroundGrid(rowEl, 0); + gridUtils._setElementToMaxZindex(backgroundGridEl, rowEl); + self.options.wysiwyg.odooEditor.observerActive('displayBackgroundGrid'); + } + + // For loop to handle the cases where it is ne, nw, se or sw. Since + // there are two directions, we compute for both directions and we + // store the values in an array. + const directions = []; + for (const [i, resize] of resizeVal.entries()) { + const props = {}; + let current = 0; + const cssProperty = resize[2]; + const cssPropertyValue = parseInt(self.$target.css(cssProperty)); + resize[0].forEach((val, key) => { + if (self.$target.hasClass(val)) { + current = key; + } else if (resize[1][key] === cssPropertyValue) { + current = key; + } + }); + + props.resize = resize; + props.current = current; + props.begin = current; + props.beginClass = self.$target.attr('class'); + props.regClass = new RegExp('\\s*' + resize[0][current].replace(/[-]*[0-9]+/, '[-]*[0-9]+'), 'g'); + props.xy = ev['page' + XY[i]]; + props.XY = XY[i]; + props.compass = compass[i]; + + directions.push(props); + } + + self.options.wysiwyg.odooEditor.automaticStepUnactive('resizing'); + + const cursor = $handle.css('cursor') + '-important'; + const $iframeWindow = $(this.ownerDocument.defaultView); + $iframeWindow[0].document.body.classList.add(cursor); + self.$overlay.removeClass('o_handlers_idle'); + + const iframeWindowMouseMove = function (ev) { + ev.preventDefault(); + + let changeTotal = false; + for (const dir of directions) { + // dd is the number of pixels by which the mouse moved, + // compared to the initial position of the handle. + const dd = ev['page' + dir.XY] - dir.xy + dir.resize[1][dir.begin]; + const next = dir.current + (dir.current + 1 === dir.resize[1].length ? 0 : 1); + const prev = dir.current ? (dir.current - 1) : 0; + + let change = false; + // If the mouse moved to the right/down by at least 2/3 of + // the space between the previous and the next steps, the + // handle is snapped to the next step and the class is + // replaced by the one matching this step. + if (dd > (2 * dir.resize[1][next] + dir.resize[1][dir.current]) / 3) { + self.$target.attr('class', (self.$target.attr('class') || '').replace(dir.regClass, '')); + self.$target.addClass(dir.resize[0][next]); + dir.current = next; + change = true; + } + // Same as above but to the left/up. + if (prev !== dir.current && dd < (2 * dir.resize[1][prev] + dir.resize[1][dir.current]) / 3) { + self.$target.attr('class', (self.$target.attr('class') || '').replace(dir.regClass, '')); + self.$target.addClass(dir.resize[0][prev]); + dir.current = prev; + change = true; + } + + if (change) { + self._onResize(dir.compass, dir.beginClass, dir.current); + } + + changeTotal = changeTotal || change; + } + + if (changeTotal) { + self.trigger_up('cover_update'); + $handle.addClass('o_active'); + } + }; + const iframeWindowMouseUp = function (ev) { + $iframeWindow.off("mousemove", iframeWindowMouseMove); + $iframeWindow.off("mouseup", iframeWindowMouseUp); + $iframeWindow[0].document.body.classList.remove(cursor); + self.$overlay.addClass('o_handlers_idle'); + $handle.removeClass('o_active'); + + // If we are in grid mode, removes the background grid. + // Also sync the col-* class with the g-col-* class so the + // toggle to normal mode and the mobile view are well done. + if (rowEl.classList.contains("o_grid_mode") && !isMobile) { + self.options.wysiwyg.odooEditor.observerUnactive('displayBackgroundGrid'); + backgroundGridEl.remove(); + self.options.wysiwyg.odooEditor.observerActive('displayBackgroundGrid'); + gridUtils._resizeGrid(rowEl); + + const colClass = [...self.$target[0].classList].find(c => /^col-/.test(c)); + const gColClass = [...self.$target[0].classList].find(c => /^g-col-/.test(c)); + self.$target[0].classList.remove(colClass); + self.$target[0].classList.add(gColClass.substring(2)); + } + + self.options.wysiwyg.odooEditor.automaticStepActive('resizing'); + + // Freeing the mutex once the resizing is done. + resizeResolve(); + self.trigger_up("enable_loading_effect"); + + // Check whether there has been a resizing. + if (directions.every(dir => dir.begin === dir.current)) { + const mouseupTime = ev.timeStamp; + // Mouse held duration in milliseconds. + const mouseHeldDuration = mouseupTime - mousedownTime; + // If no resizing happened and if the mouse was pressed less + // than 500 ms, we assume that the user wanted to click on + // the element behind the handle. + if (mouseHeldDuration < 500) { + // Find the first element behind the overlay. + const sameCoordinatesEls = self.ownerDocument + .elementsFromPoint(ev.pageX, ev.pageY); + const toBeClickedEl = sameCoordinatesEls + .find(el => !el.closest("#oe_manipulators")); + if (toBeClickedEl) { + toBeClickedEl.click(); + } + } + return; + } + + setTimeout(function () { + self.options.wysiwyg.odooEditor.historyStep(); + + self.trigger_up("snippet_edition_request", { exec: async () => { + await new Promise(resolve => { + self.trigger_up("snippet_option_update", { onSuccess: () => resolve() }); + }); + }}); + }, 0); + }; + $iframeWindow.on("mousemove", iframeWindowMouseMove); + $iframeWindow.on("mouseup", iframeWindowMouseUp); + }); + + for (const [key, value] of Object.entries(resizeValues)) { + this.$handles.filter('.' + key).toggleClass('readonly', !value); + } + if (!isMobile && this.$target[0].classList.contains("o_grid_item")) { + this.$handles.filter('.o_grid_handle').toggleClass('readonly', false); + } + + return def; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUI() { + this._updateSizingHandles(); + return this._super(...arguments); + }, + /** + * @override + */ + setTarget: function () { + this._super(...arguments); + // TODO master: _onResize should not be called here, need to check if + // updateUI is called when the target is changed + this._onResize(); + }, + /** + * @override + */ + async updateUIVisibility() { + await this._super(...arguments); + + const isMobileView = weUtils.isMobileView(this.$target[0]); + const isGridOn = this.$target[0].classList.contains("o_grid_item"); + const isGrid = !isMobileView && isGridOn; + if (this.$target[0].parentNode && this.$target[0].parentNode.classList.contains('row')) { + // Hiding/showing the correct resize handles if we are in grid mode + // or not. + for (const handleEl of this.$handles) { + const isGridHandle = handleEl.classList.contains('o_grid_handle'); + handleEl.classList.toggle('d-none', isGrid ^ isGridHandle); + // Disabling the vertical resize if we are in mobile view. + const isVerticalSizing = handleEl.matches('.n, .s'); + handleEl.classList.toggle("readonly", isMobileView && isVerticalSizing && isGridOn); + } + + // Hiding the move handle in mobile view so we can't drag the + // columns. + const moveHandleEl = this.$overlay[0].querySelector('.o_move_handle'); + moveHandleEl.classList.toggle('d-none', isMobileView); + + // Show/hide the buttons to send back/front a grid item. + const bringFrontBackEls = this.$overlay[0].querySelectorAll('.o_front_back'); + bringFrontBackEls.forEach(button => button.classList.toggle("d-none", !isGrid)); + } + }, + + //-------------------------------------------------------------------------- + // 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) { + this._updateSizingHandles(); + this._notifyResizeChange(); + }, + /** + * @private + */ + _updateSizingHandles: function () { + var self = this; + + // Adapt the resize handles according to the classes and dimensions + var resizeValues = this._getSize(); + var $handles = this.$overlay.find('.o_handle'); + for (const [direction, resizeValue] of Object.entries(resizeValues)) { + 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)); + classes.forEach((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 top and bottom sizes + this.$overlay.find('.o_handle:not(.o_grid_handle)').filter(".n, .s").toArray().forEach(handle => { + var $handle = $(handle); + var direction = $handle.hasClass('n') ? 'top' : 'bottom'; + $handle.outerHeight(self.$target.css('padding-' + direction)); + }); + }, + /** + * @override + */ + async _notifyResizeChange() { + 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; + }, +}); +registry['sizing_x'] = registry.sizing.extend({ + /** + * @override + */ + onClone: function (options) { + this._super.apply(this, arguments); + // Below condition is added to remove offset of target element only + // and not its children to avoid design alteration of a container/block. + if (options.isCurrent) { + const targetClassList = this.$target[0].classList; + const offsetClasses = [...targetClassList] + .filter(cls => cls.match(/^offset-(lg-)?([0-9]{1,2})$/)); + targetClassList.remove(...offsetClasses); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getSize: function () { + const isMobileView = weUtils.isMobileView(this.$target[0]); + const resolutionModifier = isMobileView ? "" : "lg-"; + var width = this.$target.closest('.row').width(); + var gridE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + var gridW = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + this.grid = { + e: [ + gridE.map(v => (`col-${resolutionModifier}${v}`)), + gridE.map(v => width / 12 * v), + "width", + ], + w: [ + gridW.map(v => (`offset-${resolutionModifier}${v}`)), + gridW.map(v => width / 12 * v), + "margin-left", + ], + }; + return this.grid; + }, + /** + * @override + */ + _onResize: function (compass, beginClass, current) { + const targetEl = this.$target[0]; + const isMobileView = weUtils.isMobileView(targetEl); + const resolutionModifier = isMobileView ? "" : "lg-"; + + if (compass === 'w' || compass === 'e') { + // (?!\S): following char cannot be a non-space character + const offsetRegex = new RegExp(`(?:^|\\s+)offset-${resolutionModifier}(\\d{1,2})(?!\\S)`); + const colRegex = new RegExp(`(?:^|\\s+)col-${resolutionModifier}(\\d{1,2})(?!\\S)`); + + const beginOffset = Number(beginClass.match(offsetRegex)?.[1] || 0); + + if (compass === 'w') { + // don't change the right border position when we change the offset (replace col size) + const beginCol = Number(beginClass.match(colRegex)?.[1] || 12); + let offset = Number(this.grid.w[0][current].match(offsetRegex)?.[1] || 0); + if (offset < 0) { + offset = 0; + } + let colSize = beginCol - (offset - beginOffset); + if (colSize <= 0) { + colSize = 1; + offset = beginOffset + beginCol - 1; + } + const offsetColRegex = new RegExp(`${offsetRegex.source}|${colRegex.source}`, "g"); + targetEl.className = targetEl.className.replace(offsetColRegex, ""); + targetEl.classList.add(`col-${resolutionModifier}${colSize > 12 ? 12 : colSize}`); + + if (offset > 0) { + targetEl.classList.add(`offset-${resolutionModifier}${offset}`); + } + if (isMobileView && offset === 0) { + targetEl.classList.remove("offset-lg-0"); + } else if ((isMobileView && offset > 0 && + !targetEl.className.match(/(^|\s+)offset-lg-\d{1,2}(?!\S)/)) || + (!isMobileView && offset === 0 && + targetEl.className.match(/(^|\s+)offset-\d{1,2}(?!\S)/))) { + targetEl.classList.add("offset-lg-0"); + } + } else if (beginOffset > 0) { + const endCol = Number(this.grid.e[0][current].match(colRegex)?.[1] || 0); + // Avoids overflowing the grid to the right if the + // column size + the offset exceeds 12. + if ((endCol + beginOffset) > 12) { + targetEl.className = targetEl.className.replace(colRegex, ""); + targetEl.classList.add(`col-${resolutionModifier}${12 - beginOffset}`); + } + } + } + this._super.apply(this, arguments); + }, + /** + * @override + */ + async _notifyResizeChange() { + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_column_size', + }); + this._super.apply(this, arguments); + }, +}); + +/** + * Handles the sizing in grid mode: edition of grid-{column|row}-{start|end}. + */ +registry['sizing_grid'] = registry.sizing.extend({ + /** + * @override + */ + _getSize() { + const rowEl = this.$target.closest('.row')[0]; + const gridProp = gridUtils._getGridProperties(rowEl); + + const rowStart = this.$target[0].style.gridRowStart; + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + const columnStart = this.$target[0].style.gridColumnStart; + const columnEnd = this.$target[0].style.gridColumnEnd; + + const gridN = []; + const gridS = []; + for (let i = 1; i < rowEnd + 12; i++) { + gridN.push(i); + gridS.push(i + 1); + } + const gridW = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + const gridE = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; + + this.grid = { + n: [gridN.map(v => ('g-height-' + (rowEnd - v))), gridN.map(v => ((gridProp.rowSize + gridProp.rowGap) * (v - 1))), 'grid-row-start'], + s: [gridS.map(v => ('g-height-' + (v - rowStart))), gridS.map(v => ((gridProp.rowSize + gridProp.rowGap) * (v - 1))), 'grid-row-end'], + w: [gridW.map(v => ('g-col-lg-' + (columnEnd - v))), gridW.map(v => ((gridProp.columnSize + gridProp.columnGap) * (v - 1))), 'grid-column-start'], + e: [gridE.map(v => ('g-col-lg-' + (v - columnStart))), gridE.map(v => ((gridProp.columnSize + gridProp.columnGap) * (v - 1))), 'grid-column-end'], + }; + + return this.grid; + }, + /** + * @override + */ + _onResize(compass, beginClass, current) { + if (compass === 'n') { + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + if (current < 0) { + this.$target[0].style.gridRowStart = 1; + } else if (current + 1 >= rowEnd) { + this.$target[0].style.gridRowStart = rowEnd - 1; + } else { + this.$target[0].style.gridRowStart = current + 1; + } + } else if (compass === 's') { + const rowStart = parseInt(this.$target[0].style.gridRowStart); + const rowEnd = parseInt(this.$target[0].style.gridRowEnd); + if (current + 2 <= rowStart) { + this.$target[0].style.gridRowEnd = rowStart + 1; + } else { + this.$target[0].style.gridRowEnd = current + 2; + } + + // Updating the grid height. + const rowEl = this.$target[0].parentNode; + const rowCount = parseInt(rowEl.dataset.rowCount); + const backgroundGridEl = rowEl.querySelector('.o_we_background_grid'); + const backgroundGridRowEnd = parseInt(backgroundGridEl.style.gridRowEnd); + let rowMove = 0; + if (this.$target[0].style.gridRowEnd > rowEnd && this.$target[0].style.gridRowEnd > rowCount + 1) { + rowMove = this.$target[0].style.gridRowEnd - rowEnd; + } else if (this.$target[0].style.gridRowEnd < rowEnd && this.$target[0].style.gridRowEnd >= rowCount + 1) { + rowMove = this.$target[0].style.gridRowEnd - rowEnd; + } + backgroundGridEl.style.gridRowEnd = backgroundGridRowEnd + rowMove; + } else if (compass === 'w') { + const columnEnd = parseInt(this.$target[0].style.gridColumnEnd); + if (current < 0) { + this.$target[0].style.gridColumnStart = 1; + } else if (current + 1 >= columnEnd) { + this.$target[0].style.gridColumnStart = columnEnd - 1; + } else { + this.$target[0].style.gridColumnStart = current + 1; + } + } else if (compass === 'e') { + const columnStart = parseInt(this.$target[0].style.gridColumnStart); + if (current + 2 > 13) { + this.$target[0].style.gridColumnEnd = 13; + } else if (current + 2 <= columnStart) { + this.$target[0].style.gridColumnEnd = columnStart + 1; + } else { + this.$target[0].style.gridColumnEnd = current + 2; + } + } + + if (compass === 'n' || compass === 's') { + const numberRows = this.$target[0].style.gridRowEnd - this.$target[0].style.gridRowStart; + this.$target.attr('class', this.$target.attr('class').replace(/\s*(g-height-)([0-9-]+)/g, '')); + this.$target.addClass('g-height-' + numberRows); + } + + if (compass === 'w' || compass === 'e') { + const numberColumns = this.$target[0].style.gridColumnEnd - this.$target[0].style.gridColumnStart; + this.$target.attr('class', this.$target.attr('class').replace(/\s*(g-col-lg-)([0-9-]+)/g, '')); + this.$target.addClass('g-col-lg-' + numberColumns); + } + }, +}); + +/** + * Controls box properties. + */ +registry.Box = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * TODO this should be reviewed in master to avoid the need of using the + * 'reset' previewMode and having to remember the previous box-shadow value. + * We are forced to remember the previous box shadow before applying a new + * one as the whole box-shadow value is handled by multiple widgets. + * + * @see this.selectClass for parameters + */ + async setShadow(previewMode, widgetValue, params) { + // Check if the currently configured shadow is not using the same shadow + // mode, in which case nothing has to be done. + const styles = window.getComputedStyle(this.$target[0]); + const currentBoxShadow = styles['box-shadow'] || 'none'; + const currentMode = currentBoxShadow === 'none' + ? '' + : currentBoxShadow.includes('inset') ? 'inset' : 'outset'; + if (currentMode === widgetValue) { + return; + } + + if (previewMode === true) { + this._prevBoxShadow = currentBoxShadow; + } + + // Add/remove the shadow class + this.$target.toggleClass(params.shadowClass, !!widgetValue); + + // Change the mode of the old box shadow. If no shadow was currently + // set then get the shadow value that is supposed to be set according + // to the shadow mode. Try to apply it via the selectStyle method so + // that it is either ignored because the shadow class had its effect or + // forced (to the shadow value or none) if toggling the class is not + // enough (e.g. if the item has a default shadow coming from CSS rules, + // removing the shadow class won't be enough to remove the shadow but in + // most other cases it will). + let shadow = 'none'; + if (previewMode === 'reset') { + shadow = this._prevBoxShadow; + } else { + if (currentBoxShadow === 'none') { + shadow = this._getDefaultShadow(widgetValue, params.shadowClass); + } else { + if (widgetValue === 'outset') { + shadow = currentBoxShadow.replace('inset', '').trim(); + } else if (widgetValue === 'inset') { + shadow = currentBoxShadow + ' inset'; + } + } + } + await this.selectStyle(previewMode, shadow, Object.assign({cssProperty: 'box-shadow'}, params)); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'setShadow') { + const shadowValue = this.$target.css('box-shadow'); + if (!shadowValue || shadowValue === 'none') { + return ''; + } + return this.$target.css('box-shadow').includes('inset') ? 'inset' : 'outset'; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'fake_inset_shadow_opt') { + return false; + } + return this._super(...arguments); + }, + /** + * @private + * @param {string} type + * @param {string} shadowClass + * @returns {string} + */ + _getDefaultShadow(type, shadowClass) { + if (!type) { + return 'none'; + } + + const el = document.createElement('div'); + el.classList.add(shadowClass); + document.body.appendChild(el); + const shadow = `${$(el).css('box-shadow')}${type === 'inset' ? ' inset' : ''}`; + el.remove(); + return shadow; + }, +}); + + + +registry.layout_column = SnippetOptionWidget.extend(ColumnLayoutUtils, { + /** + * @override + */ + cleanUI() { + this._removeGridPreview(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Changes the number of columns. + * + * @see this.selectClass for parameters + */ + selectCount: async function (previewMode, widgetValue, params) { + // Make sure the "Custom" option is read-only. + if (widgetValue === "custom") { + return; + } + const previousNbColumns = this.$('> .row').children().length; + let $row = this.$('> .row'); + if (!$row.length) { + const restoreCursor = preserveCursor(this.$target[0].ownerDocument); + resetOuids(this.$target[0]); + $row = this.$target.contents().wrapAll($('
')).parent().parent(); + restoreCursor(); + } + + const nbColumns = parseInt(widgetValue); + await this._updateColumnCount($row[0], (nbColumns || 1)); + // Yield UI thread to wait for event to bubble before activate_snippet is called. + // In this case this lets the select handle the click event before we switch snippet. + // TODO: make this more generic in activate_snippet event handler. + await new Promise(resolve => setTimeout(resolve)); + if (nbColumns === 0) { + const restoreCursor = preserveCursor(this.$target[0].ownerDocument); + resetOuids($row[0]); + $row.contents().unwrap().contents().unwrap(); + restoreCursor(); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } else if (previousNbColumns === 0) { + this.trigger_up('activate_snippet', {$snippet: this.$('> .row').children().first()}); + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_columns', + }); + }, + /** + * Changes the layout (columns or grid). + * + * @see this.selectClass for parameters + */ + async selectLayout(previewMode, widgetValue, params) { + if (widgetValue === "grid") { + const rowEl = this.$target[0].querySelector('.row'); + if (!rowEl || !rowEl.classList.contains('o_grid_mode')) { // Prevent toggling grid mode twice. + gridUtils._toggleGridMode(this.$target[0]); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } + } else { + // Toggle normal mode only if grid mode was activated (as it's in + // normal mode by default). + const rowEl = this.$target[0].querySelector('.row'); + if (rowEl && rowEl.classList.contains('o_grid_mode')) { + this._toggleNormalMode(rowEl); + this.trigger_up('activate_snippet', {$snippet: this.$target}); + } + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_columns', + }); + }, + /** + * Adds an image, some text or a button in the grid. + * + * @see this.selectClass for parameters + */ + async addElement(previewMode, widgetValue, params) { + const rowEl = this.$target[0].querySelector('.row'); + const elementType = widgetValue; + + // If it has been less than 15 seconds that we have added an element, + // shift the new element right and down by one cell. Otherwise, put it + // on the top left corner. + const currentTime = new Date().getTime(); + if (this.lastAddTime && (currentTime - this.lastAddTime) / 1000 < 15) { + this.lastStartPosition = [this.lastStartPosition[0] + 1, this.lastStartPosition[1] + 1]; + } else { + this.lastStartPosition = [1, 1]; // [rowStart, columnStart] + } + this.lastAddTime = currentTime; + + // Create the new column. + const newColumnEl = document.createElement('div'); + newColumnEl.classList.add('o_grid_item'); + let numberColumns, numberRows; + let imageLoadedPromise; + + if (elementType === 'image') { + // Set the columns properties. + newColumnEl.classList.add('col-lg-6', 'g-col-lg-6', 'g-height-6', 'o_grid_item_image'); + numberColumns = 6; + numberRows = 6; + + // Choose an image with the media dialog. + let isImageSaved = false; + await new Promise(resolve => { + this.call("dialog", "add", MediaDialog, { + onlyImages: true, + save: imageEl => { + isImageSaved = true; + imageLoadedPromise = new Promise(resolve => { + imageEl.addEventListener("load", () => resolve(), {once: true}); + }); + // Adds the image to the new column. + newColumnEl.appendChild(imageEl); + }, + }, { + onClose: () => resolve() + }); + }); + if (!isImageSaved) { + // Revert the current step to exclude the step saved when the + // media dialog closed. + this.options.wysiwyg.odooEditor.historyRevertCurrentStep(); + return; + } + } else if (elementType === 'text') { + newColumnEl.classList.add('col-lg-4', 'g-col-lg-4', 'g-height-2'); + numberColumns = 4; + numberRows = 2; + + // Create default text content. + const pEl = document.createElement('p'); + pEl.classList.add('o_default_snippet_text'); + pEl.textContent = _t("Write something..."); + + newColumnEl.appendChild(pEl); + } else if (elementType === 'button') { + newColumnEl.classList.add('col-lg-2', 'g-col-lg-2', 'g-height-1'); + numberColumns = 2; + numberRows = 1; + + // Create default button. + const aEl = document.createElement('a'); + aEl.href = '#'; + aEl.classList.add('mb-2', 'btn', 'btn-primary'); + aEl.textContent = "Button"; + + newColumnEl.appendChild(aEl); + } + // Place the column in the grid. + const rowStart = this.lastStartPosition[0]; + let columnStart = this.lastStartPosition[1]; + if (columnStart + numberColumns > 13) { + columnStart = 1; + this.lastStartPosition[1] = columnStart; + } + newColumnEl.style.gridArea = `${rowStart} / ${columnStart} / ${rowStart + numberRows} / ${columnStart + numberColumns}`; + + // Setting the z-index to the maximum of the grid. + gridUtils._setElementToMaxZindex(newColumnEl, rowEl); + + // Add the new column and update the grid height. + rowEl.appendChild(newColumnEl); + gridUtils._resizeGrid(rowEl); + + // Scroll to the new column if more than half of it is hidden (= out of + // the viewport or hidden by an other element). + if (elementType === "image") { + // If an image was added, wait for it to be loaded before scrolling. + await imageLoadedPromise; + } + const newColumnPosition = newColumnEl.getBoundingClientRect(); + const middleX = (newColumnPosition.left + newColumnPosition.right) / 2; + const middleY = (newColumnPosition.top + newColumnPosition.bottom) / 2; + const sameCoordinatesEl = this.ownerDocument.elementFromPoint(middleX, middleY); + if (!sameCoordinatesEl || !newColumnEl.contains(sameCoordinatesEl)) { + newColumnEl.scrollIntoView({behavior: "smooth", block: "center"}); + } + this.trigger_up('activate_snippet', {$snippet: $(newColumnEl)}); + }, + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + await this._super(previewMode, widgetValue, params); + + const rowEl = this.$target[0]; + const isMobileView = weUtils.isMobileView(rowEl); + if (["row-gap", "column-gap"].includes(params.cssProperty) && !isMobileView) { + // Reset the animation. + this._removeGridPreview(); + void rowEl.offsetWidth; // Trigger a DOM reflow. + + // Add an animated grid preview. + this.options.wysiwyg.odooEditor.observerUnactive("addGridPreview"); + this.gridPreviewEl = gridUtils._addBackgroundGrid(rowEl, 0); + this.gridPreviewEl.classList.add("o_we_grid_preview"); + gridUtils._setElementToMaxZindex(this.gridPreviewEl, rowEl); + this.options.wysiwyg.odooEditor.observerActive("addGridPreview"); + this.removeGridPreview = this._removeGridPreview.bind(this); + rowEl.addEventListener("animationend", this.removeGridPreview); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'selectCount') { + const isMobile = this._isMobile(); + const columnEls = this.$target[0].querySelector(":scope > .row")?.children; + return this._getNbColumns(columnEls, isMobile); + } else if (methodName === 'selectLayout') { + const rowEl = this.$target[0].querySelector('.row'); + if (rowEl && rowEl.classList.contains('o_grid_mode')) { + return "grid"; + } else { + return 'normal'; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'zero_cols_opt') { + // Note: "s_allow_columns" indicates containers which may have + // bare content (without columns) and are allowed to have columns. + // By extension, we only show the "None" option on elements that + // were marked as such as they were allowed to have bare content in + // the first place. + return this.$target.is('.s_allow_columns'); + } else if (widgetName === "column_count_opt") { + // Hide the selectCount widget if the `s_nb_column_fixed` class is + // on the row. + return !this.$target[0].querySelector(":scope > .row.s_nb_column_fixed"); + } else if (widgetName === "custom_cols_opt") { + // Show "Custom" if the user altered the columns in some way (i.e. + // by adding offsets or resizing a column). This is only shown as + // an indication, but shouldn't be selectable. + const isMobile = this._isMobile(); + return this.$target[0].querySelector(":scope > .row") && + this._areColsCustomized(this.$target[0].querySelector(":scope > .row").children, + isMobile); + } + return this._super(...arguments); + }, + /** + * If the number of columns requested is greater than the number of items, + * adds new columns which are clones of the last one. If there are less + * columns than the number of items, reorganizes the elements on the right + * amount of rows. + * + * @private + * @param {HTMLElement} rowEl - the row in which to update the columns + * @param {integer} nbColumns - the number of columns requested + */ + async _updateColumnCount(rowEl, nbColumns) { + const isMobile = this._isMobile(); + // The number of elements per row before the update. + const prevNbColumns = this._getNbColumns(rowEl.children, isMobile); + + if (nbColumns === prevNbColumns) { + return; + } + this._resizeColumns(rowEl.children, nbColumns); + + const itemsDelta = nbColumns - rowEl.children.length; + if (itemsDelta > 0) { + const newItems = []; + for (let i = 0; i < itemsDelta; i++) { + const lastEl = rowEl.lastElementChild; + newItems.push(new Promise(resolve => { + this.trigger_up("clone_snippet", {$snippet: $(lastEl), onSuccess: resolve}); + })); + } + await Promise.all(newItems); + } + + this.trigger_up('cover_update'); + }, + /** + * Resizes the columns for the mobile or desktop view. + * + * @private + * @param {HTMLCollection} columnEls - the elements to resize + * @param {integer} nbColumns - the number of wanted columns + */ + _resizeColumns(columnEls, nbColumns) { + const isMobile = this._isMobile(); + const itemSize = Math.floor(12 / nbColumns) || 1; + const firstItem = this._getFirstItem(columnEls, isMobile); + const firstItemOffset = Math.floor((12 - itemSize * nbColumns) / 2); + + const resolutionModifier = isMobile ? "" : "-lg"; + const replacingRegex = + // (?!\S): following char cannot be a non-space character + new RegExp(`(?:^|\\s+)(col|offset)${resolutionModifier}(-\\d{1,2})?(?!\\S)`, "g"); + + for (const columnEl of columnEls) { + columnEl.className = columnEl.className.replace(replacingRegex, ""); + columnEl.classList.add(`col${resolutionModifier}-${itemSize}`); + + if (firstItemOffset && columnEl === firstItem) { + columnEl.classList.add(`offset${resolutionModifier}-${firstItemOffset}`); + } + const hasMobileOffset = columnEl.className.match(/(^|\s+)offset-\d{1,2}(?!\S)/); + const hasDesktopOffset = columnEl.className.match(/(^|\s+)offset-lg-[1-9][0-1]?(?!\S)/); + columnEl.classList.toggle("offset-lg-0", hasMobileOffset && !hasDesktopOffset); + } + }, + /** + * Toggles the normal mode. + * + * @private + * @param {Element} rowEl + */ + async _toggleNormalMode(rowEl) { + // Removing the grid class + rowEl.classList.remove('o_grid_mode'); + const columnEls = rowEl.children; + // Removing the grid previews (if any). + await new Promise(resolve => { + this.trigger_up("clean_ui_request", { + targetEl: this.$target[0].closest("section"), + onSuccess: resolve, + }); + }); + + for (const columnEl of columnEls) { + // Reloading the images. + gridUtils._reloadLazyImages(columnEl); + // Removing the grid properties. + gridUtils._convertToNormalColumn(columnEl); + } + // Removing the grid properties. + delete rowEl.dataset.rowCount; + // Kept for compatibility. + rowEl.style.removeProperty('--grid-item-padding-x'); + rowEl.style.removeProperty('--grid-item-padding-y'); + rowEl.style.removeProperty("gap"); + }, + /** + * Removes the grid preview that was added when changing the grid gaps. + * + * @private + */ + _removeGridPreview() { + this.options.wysiwyg.odooEditor.observerUnactive("removeGridPreview"); + this.$target[0].removeEventListener("animationend", this.removeGridPreview); + if (this.gridPreviewEl) { + this.gridPreviewEl.remove(); + delete this.gridPreviewEl; + } + delete this.removeGridPreview; + this.options.wysiwyg.odooEditor.observerActive("removeGridPreview"); + }, + /** + * @returns {boolean} + */ + _isMobile() { + return weUtils.isMobileView(this.$target[0]); + }, +}); + +registry.GridColumns = SnippetOptionWidget.extend({ + /** + * @override + */ + cleanUI() { + // Remove the padding highlights. + this._removePaddingPreview(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + await this._super(...arguments); + if (["--grid-item-padding-y", "--grid-item-padding-x"].includes(params.cssProperty)) { + // Reset the animation. + this._removePaddingPreview(); + void this.$target[0].offsetWidth; // Trigger a DOM reflow. + + // Highlight the padding when changing it, by adding a pseudo- + // element with an animated colored border inside the grid item. + this.options.wysiwyg.odooEditor.observerUnactive("addPaddingPreview"); + this.$target[0].classList.add("o_we_padding_highlight"); + this.options.wysiwyg.odooEditor.observerActive("addPaddingPreview"); + this.removePaddingPreview = this._removePaddingPreview.bind(this); + this.$target[0].addEventListener("animationend", this.removePaddingPreview); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (["grid_padding_y_opt", "grid_padding_x_opt"].includes(widgetName)) { + return this.$target[0].parentElement.classList.contains("o_grid_mode"); + } + return this._super(...arguments); + }, + /** + * Removes the padding highlights that were added when changing the grid + * item padding. + * + * @private + */ + _removePaddingPreview() { + this.options.wysiwyg.odooEditor.observerUnactive("removePaddingPreview"); + this.$target[0].removeEventListener("animationend", this.removePaddingPreview); + this.$target[0].classList.remove("o_we_padding_highlight"); + delete this.removePaddingPreview; + this.options.wysiwyg.odooEditor.observerActive("removePaddingPreview"); + }, +}); + +registry.vAlignment = SnippetOptionWidget.extend({ + /** + * @override + */ + async _computeWidgetState(methodName, params) { + const value = await this._super(...arguments); + if (methodName === 'selectClass' && !value) { + // If there is no `align-items-` class on the row, then the `align- + // items-stretch` class is selected, because the behaviors are + // equivalent in both situations. + return 'align-items-stretch'; + } + return value; + }, +}); + +/** + * Allows snippets to be moved before the preceding element or after the following. + */ +registry.SnippetMove = SnippetOptionWidget.extend(ColumnLayoutUtils, { + displayOverlayOptions: true, + + /** + * @override + */ + start: function () { + var $buttons = this.$el.find('we-button'); + var $overlayArea = this.$overlay.find('.o_overlay_move_options'); + // Putting the arrows side by side. + $overlayArea.prepend($buttons[1]); + $overlayArea.prepend($buttons[0]); + + // Needed for compatibility (with already dropped snippets). + // If the target is a column, check if all the columns are either mobile + // ordered or not. If they are not consistent, then we remove the mobile + // order classes from all of them, to avoid issues. + const parentEl = this.$target[0].parentElement; + if (parentEl.classList.contains("row")) { + const columnEls = [...parentEl.children]; + const orderedColumnEls = columnEls.filter(el => el.style.order); + if (orderedColumnEls.length && orderedColumnEls.length !== columnEls.length) { + this._removeMobileOrders(orderedColumnEls); + } + } + + return this._super(...arguments); + }, + /** + * @override + */ + onClone(options) { + this._super.apply(this, arguments); + const mobileOrder = this.$target[0].style.order; + // If the order has been adapted on mobile, it must be different + // for each clone. + if (options.isCurrent && mobileOrder) { + const siblingEls = this.$target[0].parentElement.children; + const cloneEls = [...siblingEls].filter(el => el.style.order === mobileOrder); + // For cases in which multiple clones are made at the same time, we + // change the order for all clones at once. (e.g.: it happens when + // increasing the columns count.) This makes sure the clones get a + // mobile order in line with their DOM order. + cloneEls.forEach((el, i) => { + if (i > 0) { + el.style.order = siblingEls.length - cloneEls.length + i; + } + }); + } + }, + /** + * @override + */ + onMove() { + this._super.apply(this, arguments); + // Remove all the mobile order classes after a drag and drop. + this._removeMobileOrders(this.$target[0].parentElement.children); + }, + /** + * @override + */ + onRemove() { + this._super.apply(this, arguments); + const targetMobileOrder = this.$target[0].style.order; + // If the order has been adapted on mobile, the gap created by the + // removed snippet must be filled in. + if (targetMobileOrder) { + const targetOrder = parseInt(targetMobileOrder); + this._fillRemovedItemGap(this.$target[0].parentElement, targetOrder); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Moves the snippet around. + * + * @see this.selectClass for parameters + */ + moveSnippet: function (previewMode, widgetValue, params) { + const isMobile = this._isMobile(); + const isNavItem = this.$target[0].classList.contains('nav-item'); + const $tabPane = isNavItem ? $(this.$target.find('.nav-link')[0].hash) : null; + const moveLeftOrRight = ["move_left_opt", "move_right_opt"].includes(params.name); + + let siblingEls, mobileOrder; + if (moveLeftOrRight) { + siblingEls = this.$target[0].parentElement.children; + mobileOrder = !!this.$target[0].style.order; + } + if (moveLeftOrRight && isMobile && !isNavItem) { + if (!mobileOrder) { + this._addMobileOrders(siblingEls); + } + this._swapMobileOrders(widgetValue, siblingEls); + } else { + switch (widgetValue) { + case "prev": { + // Consider only visible elements. + let prevEl = this.$target[0].previousElementSibling; + while (prevEl && window.getComputedStyle(prevEl).display === "none") { + prevEl = prevEl.previousElementSibling; + } + prevEl?.insertAdjacentElement("beforebegin", this.$target[0]); + if (isNavItem) { + $tabPane.prev().before($tabPane); + } + break; + } + case "next": { + // Consider only visible elements. + let nextEl = this.$target[0].nextElementSibling; + while (nextEl && window.getComputedStyle(nextEl).display === "none") { + nextEl = nextEl.nextElementSibling; + } + nextEl?.insertAdjacentElement("afterend", this.$target[0]); + if (isNavItem) { + $tabPane.next().after($tabPane); + } + break; + } + } + if (mobileOrder) { + this._removeMobileOrders(siblingEls); + } + } + if (!this.$target.is(this.data.noScroll) + && (params.name === 'move_up_opt' || params.name === 'move_down_opt')) { + const mainScrollingEl = $().getScrollingElement()[0]; + const elTop = this.$target[0].getBoundingClientRect().top; + const heightDiff = mainScrollingEl.offsetHeight - this.$target[0].offsetHeight; + const bottomHidden = heightDiff < elTop; + const hidden = elTop < 0 || bottomHidden; + if (hidden) { + dom.scrollTo(this.$target[0], { + extraOffset: 50, + forcedOffset: bottomHidden ? heightDiff - 50 : undefined, + easing: 'linear', + duration: 500, + }); + } + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'move_snippet', + }); + // Update the "Invisible Elements" panel as the order of invisible + // snippets could have changed on the page. + this.trigger_up("update_invisible_dom"); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + const moveUpOrLeft = widgetName === "move_up_opt" || widgetName === "move_left_opt"; + const moveDownOrRight = widgetName === "move_down_opt" || widgetName === "move_right_opt"; + const moveLeftOrRight = widgetName === "move_left_opt" || widgetName === "move_right_opt"; + + if (moveUpOrLeft || moveDownOrRight) { + // The arrows are not displayed if the target is in a grid and if + // not in mobile view. + const isMobileView = weUtils.isMobileView(this.$target[0]); + if (!isMobileView && this.$target[0].classList.contains("o_grid_item")) { + return false; + } + // On mobile, items' reordering is independent from desktop inside + // a snippet (left or right), not at a higher level (up or down). + if (moveLeftOrRight && isMobileView) { + const targetMobileOrder = this.$target[0].style.order; + if (targetMobileOrder) { + const siblingEls = this.$target[0].parentElement.children; + const orderModifier = widgetName === "move_left_opt" ? -1 : 1; + let delta = 0; + while (true) { + delta += orderModifier; + const nextOrder = parseInt(targetMobileOrder) + delta; + const siblingEl = [...siblingEls].find(el => el.style.order === nextOrder.toString()); + if (!siblingEl) { + break; + } + if (window.getComputedStyle(siblingEl).display === "none") { + continue; + } + return true; + } + return false; + } + } + // Consider only visible elements. + const direction = moveUpOrLeft ? "previousElementSibling" : "nextElementSibling"; + let siblingEl = this.$target[0][direction]; + while (siblingEl && window.getComputedStyle(siblingEl).display === "none") { + siblingEl = siblingEl[direction]; + } + return !!siblingEl; + } + return this._super(...arguments); + }, + /** + * Swaps the mobile orders. + * + * @param {string} widgetValue + * @param {HTMLCollection} siblingEls + */ + _swapMobileOrders(widgetValue, siblingEls) { + const targetMobileOrder = this.$target[0].style.order; + const orderModifier = widgetValue === "prev" ? -1 : 1; + let delta = 0; + while (true) { + delta += orderModifier; + const newOrder = parseInt(targetMobileOrder) + delta; + const comparedEl = [...siblingEls].find(el => el.style.order === newOrder.toString()); + if (window.getComputedStyle(comparedEl).display === "none") { + continue; + } + this.$target[0].style.order = newOrder; + comparedEl.style.order = targetMobileOrder; + break; + } + }, + /** + * @returns {Boolean} + */ + _isMobile() { + return false; + }, +}); + +/** + * Allows for media to be replaced. + */ +registry.ReplaceMedia = SnippetOptionWidget.extend({ + init: function () { + this._super(...arguments); + this._activateLinkTool = this._activateLinkTool.bind(this); + this._deactivateLinkTool = this._deactivateLinkTool.bind(this); + }, + + /** + * @override + */ + onFocus() { + this.options.wysiwyg.odooEditor.addEventListener('activate_image_link_tool', this._activateLinkTool); + this.options.wysiwyg.odooEditor.addEventListener('deactivate_image_link_tool', this._deactivateLinkTool); + // When we start editing an image, rerender the UI to ensure the + // we-select that suggests the anchors is in a consistent state. + this.rerender = true; + }, + /** + * @override + */ + onBlur() { + this.options.wysiwyg.odooEditor.removeEventListener('activate_image_link_tool', this._activateLinkTool); + this.options.wysiwyg.odooEditor.removeEventListener('deactivate_image_link_tool', this._deactivateLinkTool); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Replaces the media. + * + * @see this.selectClass for parameters + */ + async replaceMedia() { + // open mediaDialog and replace the media. + await this.options.wysiwyg.openMediaDialog({ node:this.$target[0] }); + }, + /** + * Makes the image a clickable link by wrapping it in an
. + * This function is also called for the opposite operation. + * + * @see this.selectClass for parameters + */ + setLink(previewMode, widgetValue, params) { + const parentEl = this.$target[0].parentNode; + if (parentEl.tagName !== 'A') { + const wrapperEl = document.createElement('a'); + this.$target[0].after(wrapperEl); + wrapperEl.appendChild(this.$target[0]); + // TODO Remove when bug fixed in Chrome. + if (this.$target[0].getBoundingClientRect().width === 0) { + // Chrome lost lazy-loaded image => Force Chrome to display image. + const src = this.$target[0].src; + this.$target[0].src = ''; + this.$target[0].src = src; + } + } else { + const fragment = document.createDocumentFragment(); + fragment.append(...parentEl.childNodes); + parentEl.replaceWith(fragment); + } + }, + /** + * Changes the image link so that the URL is opened on another tab or not + * when it is clicked. + * + * @see this.selectClass for parameters + */ + setNewWindow(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + if (widgetValue) { + linkEl.setAttribute('target', '_blank'); + } else { + linkEl.removeAttribute('target'); + } + }, + /** + * Records the target url of the hyperlink. + * + * @see this.selectClass for parameters + */ + setUrl(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + let url = widgetValue; + if (!url) { + // As long as there is no URL, the image is not considered a link. + linkEl.removeAttribute('href'); + this.$target.trigger('href_changed'); + return; + } + if (!url.startsWith('/') && !url.startsWith('#') + && !/^([a-zA-Z]*.):.+$/gm.test(url)) { + // We permit every protocol (http:, https:, ftp:, mailto:,...). + // If none is explicitly specified, we assume it is a http. + url = 'http://' + url; + } + linkEl.setAttribute('href', url); + this.rerender = true; + this.$target.trigger('href_changed'); + }, + /** + * @override + */ + async updateUI() { + if (this.rerender) { + this.rerender = false; + await this._rerenderXML(); + return; + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _activateLinkTool() { + if (this.$target[0].parentElement.tagName === 'A') { + this._requestUserValueWidgets('media_url_opt')[0].focus(); + } else { + this._requestUserValueWidgets('media_link_opt')[0].enable(); + } + }, + /** + * @private + */ + _deactivateLinkTool() { + const parentEl = this.$target[0].parentNode; + if (parentEl.tagName === 'A') { + this._requestUserValueWidgets('media_link_opt')[0].enable(); + } + }, + /** + * @override + */ + _computeWidgetState(methodName, params) { + const parentEl = this.$target[0].parentElement; + const linkEl = parentEl.tagName === 'A' ? parentEl : null; + switch (methodName) { + case 'setLink': { + return linkEl ? 'true' : ''; + } + case 'setUrl': { + let href = linkEl ? linkEl.getAttribute('href') : ''; + return href || ''; + } + case 'setNewWindow': { + const target = linkEl ? linkEl.getAttribute('target') : ''; + return target && target === '_blank' ? 'true' : ''; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'media_link_opt') { + if (this.$target[0].matches('img')) { + return isImageSupportedForStyle(this.$target[0]); + } + return !this.$target[0].classList.contains('media_iframe_video'); + } + return this._super(...arguments); + }, +}); + +/* + * Abstract option to be extended by the ImageTools and BackgroundOptimize + * options that handles all the common parts. + */ +const ImageHandlerOption = SnippetOptionWidget.extend({ + /** + * @override + */ + async willStart() { + const _super = this._super.bind(this); + await this._initializeImage(); + return _super(...arguments); + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + const weightEl = document.createElement('span'); + weightEl.classList.add('o_we_image_weight', 'o_we_tag', 'd-none'); + weightEl.title = _t("Size"); + this.$weight = $(weightEl); + // Perform the loading of the image info synchronously in order to + // avoid an intermediate rendering of the Blocks tab during the + // loadImageInfo RPC that obtains the file size. + // This does not update the target. + await this._applyOptions(false); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUI() { + await this._super(...arguments); + + if (this._filesize === undefined) { + this.$weight.addClass('d-none'); + await this._applyOptions(false); + } + if (this._filesize !== undefined) { + this.$weight.text(`${this._filesize.toFixed(1)} kb`); + this.$weight.removeClass('d-none'); + this._relocateWeightEl(); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + selectFormat(previewMode, widgetValue, params) { + const values = widgetValue.split(' '); + const image = this._getImg(); + image.dataset.resizeWidth = values[0]; + if (image.dataset.shape) { + // If the image has a shape, modify its originalMimetype attribute. + image.dataset.originalMimetype = values[1]; + } else { + // If the image does not have a shape, modify its mimetype + // attribute. + image.dataset.mimetype = values[1]; + } + return this._applyOptions(); + }, + /** + * @see this.selectClass for parameters + */ + async setQuality(previewMode, widgetValue, params) { + if (previewMode) { + return; + } + 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(); + const _super = this._super.bind(this); + + // 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 'selectFormat': + return img.naturalWidth + ' ' + this._getImageMimetype(img); + 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 _super(...arguments); + }, + /** + * @abstract + */ + _relocateWeightEl() {}, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + const img = this._getImg(); + if (!this.originalSrc || !this._isImageSupportedForProcessing(img)) { + return; + } + const $select = $(uiFragment).find('we-select[data-name=format_select_opt]'); + (await this._computeAvailableFormats()).forEach(([value, [label, targetFormat]]) => { + $select.append(`${label} ${targetFormat.split('/')[1]}`); + }); + + if (!['image/jpeg', 'image/webp'].includes(this._getImageMimetype(img))) { + const optQuality = uiFragment.querySelector('we-range[data-set-quality]'); + if (optQuality) { + optQuality.remove(); + } + } + }, + /** + * Returns a list of valid formats for a given image or an empty list if + * there is no mimetypeBeforeConversion data attribute on the image. + * + * @private + */ + async _computeAvailableFormats() { + if (!this.mimetypeBeforeConversion) { + return []; + } + 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', 'image/webp'], + 256: ['256px', 'image/webp'], + 512: ['512px', 'image/webp'], + 1024: ['1024px', 'image/webp'], + 1920: ['1920px', 'image/webp'], + }; + widths[img.naturalWidth] = [_t("%spx", img.naturalWidth), 'image/webp']; + widths[optimizedWidth] = [_t("%spx (Suggested)", optimizedWidth), 'image/webp']; + const mimetypeBeforeConversion = img.dataset.mimetypeBeforeConversion; + widths[maxWidth] = [_t("%spx (Original)", maxWidth), mimetypeBeforeConversion]; + if (mimetypeBeforeConversion !== "image/webp") { + // Avoid a key collision by subtracting 0.1 - putting the webp + // above the original format one of the same size. + widths[maxWidth - 0.1] = [_t("%spx", maxWidth), 'image/webp']; + } + return Object.entries(widths) + .filter(([width]) => width <= maxWidth) + .sort(([v1], [v2]) => v1 - v2); + }, + /** + * Applies all selected options on the original image. + * + * @private + * @param {boolean} [update=true] If this is false, this does not actually + * modifies the image but only simulates the modifications on it to + * be able to update the filesize UI. + */ + async _applyOptions(update = true) { + const img = this._getImg(); + if (!update && !(img && img.complete)) { + return; + } + if (!this._isImageSupportedForProcessing(img)) { + this.originalId = null; + this._filesize = undefined; + return; + } + // Do not apply modifications if there is no original src, since it is + // needed for it. + if (!img.dataset.originalSrc) { + delete img.dataset.mimetype; + return; + } + const dataURL = await applyModifications(img, {mimetype: this._getImageMimetype(img)}); + this._filesize = getDataURLBinarySize(dataURL) / 1024; + + if (update) { + img.classList.add('o_modified_image_to_save'); + const loadedImg = await loadImage(dataURL, img); + this._applyImage(loadedImg); + // Also apply to carousel thumbnail if applicable. + weUtils.forwardToThumbnail(img); + return loadedImg; + } + return img; + }, + /** + * Loads the image's attachment info. + * + * @private + */ + async _loadImageInfo(attachmentSrc = '') { + const img = this._getImg(); + await loadImageInfo(img, attachmentSrc); + if (!img.dataset.originalId) { + this.originalId = null; + this.originalSrc = null; + return; + } + this.originalId = img.dataset.originalId; + this.originalSrc = img.dataset.originalSrc; + this.mimetypeBeforeConversion = img.dataset.mimetypeBeforeConversion; + }, + /** + * Sets the image's width to its suggested size. + * + * @private + */ + async _autoOptimizeImage() { + await this._loadImageInfo(); + await this._rerenderXML(); + const img = this._getImg(); + if (!['image/gif', 'image/svg+xml'].includes(img.dataset.mimetype)) { + // Convert to recommended format and width. + img.dataset.mimetype = 'image/webp'; + img.dataset.resizeWidth = this.optimizedWidth; + } else if (img.dataset.shape && img.dataset.originalMimetype !== "image/gif") { + img.dataset.originalMimetype = "image/webp"; + img.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) {}, + /** + * @private + * @param {HTMLImageElement} img + * @returns {String} The right mimetype used to apply options on image. + */ + _getImageMimetype(img) { + return img.dataset.mimetype; + }, + /** + * @private + */ + async _initializeImage() { + return this._loadImageInfo(); + }, + /** + * @private + * @param {HTMLImageElement} img + * @param {Boolean} [strict=false] + * @returns {Boolean} + */ + _isImageSupportedForProcessing(img, strict = false) { + return isImageSupportedForProcessing(this._getImageMimetype(img), strict); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (widgetName === "format_select_opt" && !this.mimetypeBeforeConversion) { + return false; + } + if (this._isImageProcessingWidget(widgetName, params)) { + const img = this._getImg(); + return this._isImageSupportedForProcessing(img, true); + } + return isImageSupportedForStyle(this._getImg()); + }, + /** + * Indicates if an option should be applied only on supported mimetypes. + * + * @param {String} widgetName + * @param {Object} params + * @returns {Boolean} + */ + _isImageProcessingWidget(widgetName, params) { + return params.optionsPossibleValues.glFilter + || 'customFilter' in params.optionsPossibleValues + || params.optionsPossibleValues.setQuality + || widgetName === 'format_select_opt'; + }, +}); + +/** + * @param {Element} containerEl + * @param {boolean} labelIsDimension - Optional display imgsize attribute instead of animated + * @returns {Element} + */ +const _addAnimatedShapeLabel = function addAnimatedShapeLabel(containerEl, labelIsDimension = false) { + const labelEl = document.createElement('span'); + labelEl.classList.add('o_we_shape_animated_label'); + let labelStr = _t("Animated"); + const spanEl = document.createElement('span'); + if (labelIsDimension) { + const dimensionIcon = document.createElement('i'); + labelStr = containerEl.dataset.imgSize; + dimensionIcon.classList.add('fa', 'fa-expand'); + labelEl.append(dimensionIcon); + spanEl.textContent = labelStr; + } else { + labelEl.textContent = labelStr[0]; + spanEl.textContent = labelStr.substr(1); + } + labelEl.appendChild(spanEl); + containerEl.classList.add('position-relative'); + containerEl.appendChild(labelEl); + return labelEl; +}; + +/** + * Controls image width and quality. + */ +registry.ImageTools = ImageHandlerOption.extend({ + MAX_SUGGESTED_WIDTH: 1920, + + /** + * @constructor + */ + init() { + this.shapeCache = {}; + return this._super(...arguments); + }, + /** + * @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); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Displays the image cropping tools + * + * @see this.selectClass for parameters + */ + async crop() { + this.trigger_up('disable_loading_effect'); + const img = this._getImg(); + const document = this.$el[0].ownerDocument; + const imageCropWrapperElement = document.createElement('div'); + document.body.append(imageCropWrapperElement); + const imageCropWrapper = await attachComponent(this, imageCropWrapperElement, ImageCrop, { + activeOnStart: true, + media: img, + mimetype: this._getImageMimetype(img), + }); + + await new Promise(resolve => { + this.$target.one('image_cropper_destroyed', async () => { + if (isGif(this._getImageMimetype(img))) { + img.dataset[img.dataset.shape ? 'originalMimetype' : 'mimetype'] = 'image/png'; + } + await this._reapplyCurrentShape(); + resolve(); + }); + }); + imageCropWrapperElement.remove(); + imageCropWrapper.destroy(); + this.trigger_up('enable_loading_effect'); + }, + /** + * Displays the image transformation tools + * + * @see this.selectClass for parameters + */ + async transform() { + this.trigger_up('hide_overlay'); + this.trigger_up('disable_loading_effect'); + + const document = this.$target[0].ownerDocument; + const playState = this.$target[0].style.animationPlayState; + const transition = this.$target[0].style.transition; + this.$target.transfo({document}); + const destroyTransfo = () => { + this.$target.transfo('destroy'); + $(document).off('mousedown', mousedown); + window.document.removeEventListener('keydown', keydown); + } + const mousedown = mousedownEvent => { + if (!$(mousedownEvent.target).closest('.transfo-container').length) { + destroyTransfo(); + // Restore animation css properties potentially affected by the + // jQuery transfo plugin. + this.$target[0].style.animationPlayState = playState; + this.$target[0].style.transition = transition; + } + }; + $(document).on('mousedown', mousedown); + const keydown = keydownEvent => { + if (keydownEvent.key === 'Escape') { + keydownEvent.stopImmediatePropagation(); + destroyTransfo(); + } + }; + window.document.addEventListener('keydown', keydown); + + await new Promise(resolve => { + document.addEventListener('mouseup', resolve, {once: true}); + }); + this.trigger_up('enable_loading_effect'); + }, + /** + * Resets the image cropping + * + * @see this.selectClass for parameters + */ + async resetCrop() { + const img = this._getImg(); + + // Mount the ImageCrop to call the reset method. As we need the state of + // the component to be mounted before calling reset, mount it + // temporarily into the body. + const imageCropWrapperElement = document.createElement('div'); + document.body.append(imageCropWrapperElement); + const imageCropWrapper = await attachComponent(this, imageCropWrapperElement, ImageCrop, { + activeOnStart: true, + media: img, + mimetype: this._getImageMimetype(img), + }); + await imageCropWrapper.component.mountedPromise; + await imageCropWrapper.component.reset(); + imageCropWrapper.destroy(); + imageCropWrapperElement.remove(); + + await this._reapplyCurrentShape(); + }, + /** + * Resets the image rotation and translation + * + * @see this.selectClass for parameters + */ + async resetTransform() { + this.$target + .attr('style', (this.$target.attr('style') || '') + .replace(/[^;]*transform[\w:]*;?/g, '')); + }, + /** + * @see this.selectClass for parameters + */ + async setImgShape(previewMode, widgetValue, params) { + const img = this._getImg(); + const saveData = previewMode === false; + if (img.dataset.hoverEffect && !widgetValue) { + // When a shape is removed and there is a hover effect on the + // image, we then place the "Square" shape as the default because a + // shape is required for the hover effects to work. + const shapeImgSquareWidget = this._requestUserValueWidgets("shape_img_square_opt")[0]; + widgetValue = shapeImgSquareWidget.getActiveValue("setImgShape"); + } + if (widgetValue) { + await this._loadShape(widgetValue); + if (previewMode === 'reset' && img.dataset.shapeColors) { + // When we reset the shape we need to reapply the colors the + // user had selected. + await this._applyShapeAndColors(false, img.dataset.shapeColors.split(';')); + } else { + // If the preview mode === false we want to save the colors + // as the user chose their shape + await this._applyShapeAndColors(saveData); + if (saveData && img.dataset.mimetype !== 'image/svg+xml') { + img.dataset.originalMimetype = img.dataset.mimetype; + img.dataset.mimetype = 'image/svg+xml'; + } + // When the user selects a shape, we remove the data attributes + // that are not compatible with this shape. + if (saveData) { + if (!this._isTransformableShape()) { + delete img.dataset.shapeFlip; + delete img.dataset.shapeRotate; + } + if (!this._canHaveHoverEffect()) { + delete img.dataset.hoverEffect; + delete img.dataset.hoverEffectColor; + delete img.dataset.hoverEffectStrokeWidth; + delete img.dataset.hoverEffectIntensity; + img.classList.remove("o_animate_on_hover"); + } + if (!this._isAnimatedShape()) { + delete img.dataset.shapeAnimationSpeed; + } + } + } + } else { + // Re-applying the modifications and deleting the shapes + img.src = await applyModifications(img, {mimetype: this._getImageMimetype(img)}); + delete img.dataset.shape; + delete img.dataset.shapeColors; + delete img.dataset.fileName; + delete img.dataset.shapeFlip; + delete img.dataset.shapeRotate; + delete img.dataset.shapeAnimationSpeed; + if (saveData) { + img.dataset.mimetype = img.dataset.originalMimetype; + delete img.dataset.originalMimetype; + } + // Also apply to carousel thumbnail if applicable. + weUtils.forwardToThumbnail(img); + } + img.classList.add('o_modified_image_to_save'); + }, + /** + * Handles color assignment on the shape. Widget is a color picker. + * If no value, we reset to the current color palette. + * + * @see this.selectClass for parameters + */ + async setImgShapeColor(previewMode, widgetValue, params) { + const img = this._getImg(); + const newColorId = parseInt(params.colorId); + const oldColors = img.dataset.shapeColors.split(';'); + const newColors = oldColors.slice(0); + newColors[newColorId] = this._getCSSColorValue(widgetValue === '' ? `o-color-${(newColorId + 1)}` : widgetValue); + await this._applyShapeAndColors(true, newColors); + img.classList.add('o_modified_image_to_save'); + }, + /** + * Flips the image shape horizontally. + * + * @see this.selectClass for parameters + */ + async setImgShapeFlipX(previewMode, widgetValue, params) { + await this._setImgShapeFlip("x"); + }, + /** + * Flips the image shape vertically. + * + * @see this.selectClass for parameters + */ + async setImgShapeFlipY(previewMode, widgetValue, params) { + await this._setImgShapeFlip("y"); + }, + /** + * Rotates the image shape 90 degrees to the left. + * + * @see this.selectClass for parameters + */ + async setImgShapeRotateLeft(previewMode, widgetValue, params) { + await this._setImgShapeRotate(-90); + }, + /** + * Rotates the image shape 90 degrees to the right. + * + * @see this.selectClass for parameters + */ + async setImgShapeRotateRight(previewMode, widgetValue, params) { + await this._setImgShapeRotate(90); + }, + /** + * Sets the hover effects of the image shape. + * + * @see this.selectClass for parameters + */ + async setImgShapeHoverEffect(previewMode, widgetValue, params) { + const imgEl = this._getImg(); + if (previewMode !== "reset") { + this.prevHoverEffectColor = imgEl.dataset.hoverEffectColor; + this.prevHoverEffectIntensity = imgEl.dataset.hoverEffectIntensity; + this.prevHoverEffectStrokeWidth = imgEl.dataset.hoverEffectStrokeWidth; + } + delete imgEl.dataset.hoverEffectColor; + delete imgEl.dataset.hoverEffectIntensity; + delete imgEl.dataset.hoverEffectStrokeWidth; + if (previewMode === true) { + if (params.name === "hover_effect_overlay_opt") { + imgEl.dataset.hoverEffectColor = this._getCSSColorValue("black-25"); + } else if (params.name === "hover_effect_outline_opt") { + imgEl.dataset.hoverEffectColor = this._getCSSColorValue("primary"); + imgEl.dataset.hoverEffectStrokeWidth = 10; + } else { + imgEl.dataset.hoverEffectIntensity = 20; + if (params.name !== "hover_effect_mirror_blur_opt") { + imgEl.dataset.hoverEffectColor = "rgba(0, 0, 0, 0)"; + } + } + } else { + if (this.prevHoverEffectColor) { + imgEl.dataset.hoverEffectColor = this.prevHoverEffectColor; + } + if (this.prevHoverEffectIntensity) { + imgEl.dataset.hoverEffectIntensity = this.prevHoverEffectIntensity; + } + if (this.prevHoverEffectStrokeWidth) { + imgEl.dataset.hoverEffectStrokeWidth = this.prevHoverEffectStrokeWidth; + } + } + await this._reapplyCurrentShape(); + // When the hover effects are first activated from the "animationMode" + // function of the "WebsiteAnimate" class, the history was paused to + // avoid recording intermediate steps. That's why we unpause it here. + if (this.firstHoverEffect) { + this.options.wysiwyg.odooEditor.historyUnpauseSteps(); + delete this.firstHoverEffect; + } + }, + /** + * @see this.selectClass for parameters + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await this._super(...arguments); + if (["shapeAnimationSpeed", "hoverEffectIntensity", "hoverEffectStrokeWidth"].includes(params.attributeName)) { + await this._reapplyCurrentShape(); + } + }, + /** + * Sets the color of hover effects. + * + * @see this.selectClass for parameters + */ + async setHoverEffectColor(previewMode, widgetValue, params) { + const img = this._getImg(); + let defaultColor = "rgba(0, 0, 0, 0)"; + if (img.dataset.hoverEffect === "overlay") { + defaultColor = "black-25"; + } else if (img.dataset.hoverEffect === "outline") { + defaultColor = "primary"; + } + img.dataset.hoverEffectColor = this._getCSSColorValue(widgetValue || defaultColor); + await this._reapplyCurrentShape(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + notify(name) { + if (name === "enable_hover_effect") { + this.trigger_up("snippet_edition_request", {exec: () => { + // Add the "square" shape to the image if it has no shape + // because the "hover effects" need a shape to work. + const imgEl = this._getImg(); + const shapeName = imgEl.dataset.shape?.split("/")[2]; + if (!shapeName) { + const shapeImgSquareWidget = this._requestUserValueWidgets("shape_img_square_opt")[0]; + shapeImgSquareWidget.enable(); + shapeImgSquareWidget.getParent().close(); // FIXME remove this ugly hack asap + } + // Add the "Overlay" hover effect to the shape. + this.firstHoverEffect = true; + const hoverEffectOverlayWidget = this._requestUserValueWidgets("hover_effect_overlay_opt")[0]; + hoverEffectOverlayWidget.enable(); + hoverEffectOverlayWidget.getParent().close(); // FIXME remove this ugly hack asap + }}); + } else if (name === "disable_hover_effect") { + this._disableHoverEffect(); + } else { + this._super(...arguments); + } + }, + /** + * @override + */ + async updateUI() { + await this._super(...arguments); + // Adapts the colorpicker label according to the selected "On Hover" + // animation. + const hoverEffectName = this.$target[0].dataset.hoverEffect; + if (hoverEffectName) { + const hoverEffectColorWidget = this.findWidget("hover_effect_color_opt"); + const needToAdaptLabel = ["image_zoom_in", "image_zoom_out", "dolly_zoom"].includes(hoverEffectName); + const labelEl = hoverEffectColorWidget.el.querySelector("we-title"); + if (!this._originalHoverEffectColorLabel) { + this._originalHoverEffectColorLabel = labelEl.textContent; + } + labelEl.textContent = needToAdaptLabel + ? _t("Overlay") + : this._originalHoverEffectColorLabel; + } + // Move the "hover effects" options to the 'websiteAnimate' options. + const hoverEffectsOptionsEl = this.$el[0].querySelector("#o_hover_effects_options"); + const animationEffectWidget = this._requestUserValueWidgets("animation_effect_opt")[0]; + if (hoverEffectsOptionsEl && animationEffectWidget) { + animationEffectWidget.getParent().$el[0].append(hoverEffectsOptionsEl); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _isTransformed() { + return this.$target.is('[style*="transform"]'); + }, + /** + * @private + */ + _isCropped() { + return this.$target.hasClass('o_we_image_cropped'); + }, + /** + * @override + */ + async _applyOptions() { + const img = await this._super(...arguments); + if (img && img.dataset.shape) { + await this._loadShape(img.dataset.shape); + if (/^data:/.test(img.src)) { + // Reapplying the shape + await this._applyShapeAndColors(true, (img.dataset.shapeColors && img.dataset.shapeColors.split(';'))); + } + } + return img; + }, + /** + * Loads the shape into cache if not already and sets it in the dataset of + * the img + * + * @param {string} shapeName identifier of the shape + */ + async _loadShape(shapeName) { + const [module, directory, fileName] = shapeName.split('/'); + let shape = this.shapeCache[fileName]; + if (!shape) { + const shapeURL = `/${encodeURIComponent(module)}/static/image_shapes/${encodeURIComponent(directory)}/${encodeURIComponent(fileName)}.svg`; + shape = await (await fetch(shapeURL)).text(); + this.shapeCache[fileName] = shape; + } + this._getImg().dataset.shape = shapeName; + }, + + /** + * Applies the shape in img.dataset.shape and replaces the previous hex + * color values with new ones or current theme + * ones then calls _writeShape() + * + * @param {boolean} save true if the colors need to be saved in the + * data-attribute + * @param {string[]} [newColors] Array of HEX color code, default + * theme colors are applied if not supplied + */ + async _applyShapeAndColors(save, newColors) { + const img = this._getImg(); + let shape = this.shapeCache[img.dataset.shape.split('/')[2]]; + + // Map the default palette colors to an array if the shape includes them + // If they do not map a NULL, this way we know if a default color is in + // the shape + const oldColors = Object.values(DEFAULT_PALETTE).map(color => shape.includes(color) ? color : null); + if (!newColors) { + // If we do not have newColors, we still replace the default + // shape's colors by the current palette's + newColors = oldColors.map((color, i) => color !== null ? this._getCSSColorValue(`o-color-${(i + 1)}`) : null); + } + newColors.forEach((color, i) => shape = shape.replace(new RegExp(oldColors[i], 'g'), this._getCSSColorValue(color))); + await this._writeShape(shape); + if (save) { + img.dataset.shapeColors = newColors.join(';'); + } + // Also apply to carousel thumbnail if applicable. + weUtils.forwardToThumbnail(img); + }, + /** + * Replace animation durations in SVG and CSS with modified values. + * + * This function takes a ratio and an SVG string containing animations. It + * uses regular expressions to find and replace the duration values in both + * CSS animation rules and SVG duration attributes based on the provided + * ratio. + * + * @param {number} speed The speed used to calculate the new animation + * durations. If speed is 0.0, the original + * durations are preserved. + * @param {string} svg The SVG string containing animations. + * @returns {string} The modified SVG string with updated animation + * durations. + */ + _replaceAnimationDuration(speed, svg) { + const ratio = (speed >= 0.0 ? 1.0 + speed : 1.0 / (1.0 - speed)).toFixed(3); + // Callback for CSS 'animation' and 'animation-duration' declarations + function callbackCssAnimationRule(match, declaration, value, unit, separator) { + value = parseFloat(value) / (ratio ? ratio : 1); + return `${declaration}${value}${unit}${separator}`; + } + + // Callback function for handling the 'dur' SVG attribute timecount + // value in accordance with the SMIL animation specification (e.g., 4s, + // 2ms). If no unit is provided, seconds are implied. + function callbackSvgDurTimecountVal(match, attribute_name, value, unit) { + value = parseFloat(value) / (ratio ? ratio : 1); + return `${attribute_name}${value}${unit ? unit : 's'}"` + } + + // Applying regex substitutions to modify animation speed in the 'svg' + // variable. + svg = svg.replace(CSS_ANIMATION_RULE_REGEX, callbackCssAnimationRule); + svg = svg.replace(SVG_DUR_TIMECOUNT_VAL_REGEX, callbackSvgDurTimecountVal); + if (CSS_ANIMATION_RATIO_REGEX.test(svg)) { + // Replace the CSS --animation_ratio variable for future purpose. + svg = svg.replace(CSS_ANIMATION_RATIO_REGEX, `--animation_ratio: ${ratio};`); + } else { + // Add the style tag with the root variable --animation ratio for + // future purpose. + const regex = //m; + const subst = `$&\n\t`; + svg = svg.replace(regex, subst); + } + return svg; + }, + /** + * Sets the image in the supplied SVG and replace the src with a dataURL + * + * @param {string} svgText svg file as text + * @returns {Promise} resolved once the svg is properly loaded + * in the document + */ + async _writeShape(svgText) { + const img = this._getImg(); + let needToRefreshPublicWidgets = false; + let hasHoverEffect = false; + + // Add shape animations on hover. + if (img.dataset.hoverEffect && this._canHaveHoverEffect()) { + // The "ImageShapeHoverEffet" public widget needs to restart + // (e.g. image replacement). + needToRefreshPublicWidgets = true; + hasHoverEffect = true; + } + + const dataURL = await this.computeShape(svgText, img); + + let clonedImgEl = null; + if (hasHoverEffect) { + // This is useful during hover effects previews. Without this, in + // Chrome, the 'mouse out' animation is triggered very briefly when + // previewMode === 'reset' (when transitioning from one hover effect + // to another), causing a visual glitch. To avoid this, we hide the + // image with its clone when the source is set. + clonedImgEl = img.cloneNode(true); + this.options.wysiwyg.odooEditor.observerUnactive("addClonedImgForHoverEffectPreview"); + img.classList.add("d-none"); + img.insertAdjacentElement("afterend", clonedImgEl); + this.options.wysiwyg.odooEditor.observerActive("addClonedImgForHoverEffectPreview"); + } + const loadedImg = await loadImage(dataURL, img); + if (hasHoverEffect) { + this.options.wysiwyg.odooEditor.observerUnactive("removeClonedImgForHoverEffectPreview"); + clonedImgEl.remove(); + img.classList.remove("d-none"); + this.options.wysiwyg.odooEditor.observerActive("removeClonedImgForHoverEffectPreview"); + } + if (needToRefreshPublicWidgets) { + await this._refreshPublicWidgets(); + } + return loadedImg; + }, + /** + * Sets the image in the supplied SVG and replace the src with a dataURL + * + * @param {string} svgText svg text file + * @param img JQuery image + * @returns {Promise} resolved once the svg is properly loaded + * in the document + */ + async computeShape(svgText, img) { + const initialImageWidth = img.naturalWidth; + + // Apply the right animation speed if there is an animated shape. + const shapeAnimationSpeed = Number(img.dataset.shapeAnimationSpeed) || 0; + if (shapeAnimationSpeed) { + svgText = this._replaceAnimationDuration(shapeAnimationSpeed, svgText); + } + + const svg = new DOMParser().parseFromString(svgText, 'image/svg+xml').documentElement; + + // Modifies the SVG according to the "flip" or/and "rotate" options. + const shapeFlip = img.dataset.shapeFlip || ""; + const shapeRotate = img.dataset.shapeRotate || 0; + if ((shapeFlip || shapeRotate) && this._isTransformableShape()) { + let shapeTransformValues = []; + if (shapeFlip) { // Possible values => "x", "y", "xy" + shapeTransformValues.push(`scale${shapeFlip === "x" ? "X" : shapeFlip === "y" ? "Y" : ""}(-1)`); + } + if (shapeRotate) { // Possible values => "90", "180", "270" + shapeTransformValues.push(`rotate(${shapeRotate}deg)`); + } + // "transform-origin: center;" does not work on "#filterPath". But + // since its dimension is 1px * 1px the following solution works. + const transformOrigin = "transform-origin: 0.5px 0.5px;"; + // Applies the transformation values to the path used to create a + // mask over the SVG image. + svg.querySelector("#filterPath").setAttribute("style", `transform: ${shapeTransformValues.join(" ")}; ${transformOrigin}`); + } + + // Add shape animations on hover. + if (img.dataset.hoverEffect && this._canHaveHoverEffect()) { + this._addImageShapeHoverEffect(svg, img); + } + + const svgAspectRatio = parseInt(svg.getAttribute('width')) / parseInt(svg.getAttribute('height')); + // We will store the image in base64 inside the SVG. + // applyModifications will return a dataURL with the current filters + // and size options. + const options = { + mimetype: this._getImageMimetype(img), + perspective: svg.dataset.imgPerspective || null, + imgAspectRatio: svg.dataset.imgAspectRatio || null, + svgAspectRatio: svgAspectRatio, + }; + const imgDataURL = await applyModifications(img, options); + svg.removeChild(svg.querySelector('#preview')); + svg.querySelectorAll("image").forEach(image => { + image.setAttribute("xlink:href", imgDataURL); + }); + // Force natural width & height (note: loading the original image is + // needed for Safari where natural width & height of SVG does not return + // the correct values). + const originalImage = await loadImage(imgDataURL); + // If the svg forces the size of the shape we still want to have the resized + // width + if (!svg.dataset.forcedSize) { + svg.setAttribute('width', originalImage.naturalWidth); + svg.setAttribute('height', originalImage.naturalHeight); + } else { + const imageWidth = Math.trunc(img.dataset.resizeWidth || img.dataset.width || initialImageWidth); + const newHeight = imageWidth / svgAspectRatio; + svg.setAttribute('width', imageWidth); + svg.setAttribute('height', newHeight); + } + // Transform the current SVG in a base64 file to be saved by the server + const blob = new Blob([svg.outerHTML], { + type: 'image/svg+xml', + }); + const dataURL = await createDataURL(blob); + const imgFilename = (img.dataset.originalSrc.split('/').pop()).split('.')[0]; + img.dataset.fileName = `${imgFilename}.svg`; + return dataURL; + }, + /** + * @override + */ + _computeMaxDisplayWidth() { + const img = this._getImg(); + const computedStyles = window.getComputedStyle(img); + const displayWidth = parseFloat(computedStyles.getPropertyValue('width')); + const gutterWidth = parseFloat(computedStyles.getPropertyValue('--o-grid-gutter-width')) || 30; + + // For the logos we don't want to suggest a width too small. + if (this.$target[0].closest('nav')) { + return Math.round(Math.min(displayWidth * 3, this.MAX_SUGGESTED_WIDTH)); + // If the image is in a container(-small), it might get bigger on + // smaller screens. So we suggest the width of the current image unless + // it is smaller than the size of the container on the md breapoint + // (which is where our bootstrap columns fallback to full container + // width since we only use col-lg-* in Odoo). + } else if (img.closest('.container, .o_container_small')) { + const mdContainerMaxWidth = parseFloat(computedStyles.getPropertyValue('--o-md-container-max-width')) || 720; + const mdContainerInnerWidth = mdContainerMaxWidth - gutterWidth; + return Math.round(clamp(displayWidth, mdContainerInnerWidth, this.MAX_SUGGESTED_WIDTH)); + // If the image is displayed in a container-fluid, it might also get + // bigger on smaller screens. The same way, we suggest the width of the + // current image unless it is smaller than the max size of the container + // on the md breakpoint (which is the LG breakpoint since the container + // fluid is full-width). + } else if (img.closest('.container-fluid')) { + const lgBp = parseFloat(computedStyles.getPropertyValue('--breakpoint-lg')) || 992; + const mdContainerFluidMaxInnerWidth = lgBp - gutterWidth; + return Math.round(clamp(displayWidth, mdContainerFluidMaxInnerWidth, this.MAX_SUGGESTED_WIDTH)); + } + // If it's not in a container, it's probably not going to change size + // depending on breakpoints. We still keep a margin safety. + return Math.round(Math.min(displayWidth * 1.5, this.MAX_SUGGESTED_WIDTH)); + }, + /** + * @override + */ + _getImg() { + return this.$target[0]; + }, + /** + * @override + */ + _relocateWeightEl() { + const leftPanelEl = this.$overlay.data('$optionsSection')[0]; + const titleTextEl = leftPanelEl.querySelector('we-title > span'); + this.$weight.appendTo(titleTextEl); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName.startsWith('img-shape-color')) { + const img = this._getImg(); + const shapeName = img.dataset.shape; + const shapeColors = img.dataset.shapeColors; + if (!shapeName || !shapeColors) { + return false; + } + const colors = img.dataset.shapeColors.split(';'); + return colors[parseInt(params.colorId)]; + } + if (widgetName === "shape_anim_speed_opt") { + return this._isAnimatedShape(); + } + if (params.optionsPossibleValues.resetTransform) { + return this._isTransformed(); + } + if (params.optionsPossibleValues.resetCrop) { + return this._isCropped(); + } + if (params.optionsPossibleValues.crop) { + const img = this._getImg(); + return isImageSupportedForStyle(img) || this._isImageSupportedForProcessing(img); + } + if (["img_shape_transform_flip_x_opt", "img_shape_transform_flip_y_opt", + "img_shape_transform_rotate_x_opt", "img_shape_transform_rotate_y_opt"].includes(params.name)) { + return this._isTransformableShape(); + } + if (widgetName === "hover_effect_none_opt") { + // The hover effects are removed with the "WebsiteAnimate" animation + // selector so this option should not be visible. + return false; + } + if (params.optionsPossibleValues.setImgShapeHoverEffect) { + const imgEl = this._getImg(); + return imgEl.classList.contains("o_animate_on_hover") && this._canHaveHoverEffect(); + } + // If "Description" or "Tooltip" options. + if (["alt", "title"].includes(params.attributeName)) { + return isImageSupportedForStyle(this._getImg()); + } + // The "Square" shape is only used for hover effects. It is + // automatically set when there is an hover effect and no shape is + // chosen by the user. This shape is always hidden in the shape select. + if (widgetName === "shape_img_square_opt") { + return false; + } + if (widgetName === "remove_img_shape_opt") { + // Do not show the "remove shape" button when the "square" shape is + // enable. The "square" shape is only enable when there is a hover + // effect and it is always hidden in the shape select. + const shapeImgSquareWidget = this._requestUserValueWidgets("shape_img_square_opt")[0]; + return !shapeImgSquareWidget.isActive(); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetState(methodName, params) { + switch (methodName) { + case 'selectStyle': { + if (params.cssProperty === 'width') { + // TODO check how to handle this the right way (here using + // inline style instead of computed because of the messy + // %-px convertion and the messy auto keyword). + const width = this.$target[0].style.width.trim(); + if (width[width.length - 1] === '%') { + return `${parseInt(width)}%`; + } + return ''; + } + break; + } + case 'transform': { + return this._isTransformed() ? 'true' : ''; + } + case 'crop': { + return this._isCropped() ? 'true' : ''; + } + case 'setImgShape': { + return this._getImg().dataset.shape || ''; + } + case 'setImgShapeColor': { + const img = this._getImg(); + return (img.dataset.shapeColors && img.dataset.shapeColors.split(';')[parseInt(params.colorId)]) || ''; + } + case 'setImgShapeFlipX': { + const imgEl = this._getImg(); + return imgEl.dataset.shapeFlip?.includes("x") || ""; + } + case 'setImgShapeFlipY': { + const imgEl = this._getImg(); + return imgEl.dataset.shapeFlip?.includes("y") || ""; + } + case 'setHoverEffectColor': { + const imgEl = this._getImg(); + return imgEl.dataset.hoverEffectColor || ""; + } + } + return this._super(...arguments); + }, + /** + * Appends the SVG as an image. + * Due to the nature of image_shapes' SVGs, it is easier to render them as + * img compared to appending their content to the DOM + * (which is what the current data-img does) + * + * @override + */ + async _renderCustomXML(uiFragment) { + await this._super(...arguments); + uiFragment.querySelectorAll('we-select-page we-button[data-set-img-shape]').forEach(btn => { + const image = document.createElement('img'); + const [moduleName, directory, shapeName] = btn.dataset.setImgShape.split('/'); + image.src = `/${encodeURIComponent(moduleName)}/static/image_shapes/${encodeURIComponent(directory)}/${encodeURIComponent(shapeName)}.svg`; + $(btn).prepend(image); + + if (btn.dataset.animated) { + _addAnimatedShapeLabel(btn); + } else if (btn.dataset.imgSize) { + _addAnimatedShapeLabel(btn, true); + } + }); + }, + /** + * @override + */ + _getImageMimetype(img) { + if (img.dataset.shape && img.dataset.originalMimetype) { + return img.dataset.originalMimetype; + } + return this._super(...arguments); + }, + /** + * Gets the CSS value of a color variable name so it can be used on shapes. + * + * @param {string} color + * @returns {string} + */ + _getCSSColorValue(color) { + if (!color || isCSSColor(color)) { + return color; + } + return weUtils.getCSSVariableValue(color); + }, + /** + * Overridden to set attachment data on theme images (with default shapes). + * + * @override + * @private + */ + async _initializeImage() { + const _super = this._super.bind(this); + let img = this._getImg(); + + // Check first if the `src` and eventual `data-original-src` attributes + // are correct (i.e. the await are not rejected), as they may have been + // wrongly hardcoded in some templates. + let checkedAttribute = 'src'; + try { + await loadImage(img.src); + if (img.dataset.originalSrc) { + checkedAttribute = 'originalSrc'; + await loadImage(img.dataset.originalSrc); + } + } catch { + if (checkedAttribute === 'src') { + // If `src` does not exist, replace the image by a placeholder. + Object.keys(img.dataset).forEach(key => delete img.dataset[key]); + img.dataset.mimetype = 'image/png'; + const newSrc = '/web/image/web.image_placeholder'; + img = await loadImage(newSrc, img); + return this._loadImageInfo(newSrc); + } else { + // If `data-original-src` does not exist, remove the `data- + // original-*` attributes (they will be set correctly afterwards + // in `_loadImageInfo`). + delete img.dataset.originalId; + delete img.dataset.originalSrc; + delete img.dataset.originalMimetype; + } + } + + let match = img.src.match(/\/web_editor\/image_shape\/(\w+\.\w+)/); + if (img.dataset.shape && match) { + match = match[1]; + if (match.endsWith("_perspective")) { + // As an image might already have been modified with a + // perspective for some customized snippets in themes. We need + // to find the original image to set the 'data-original-src' + // attribute. + match = match.slice(0, -12); + } + return this._loadImageInfo(`/web/image/${encodeURIComponent(match)}`); + } + return _super(...arguments); + }, + /** + * @override + * @private + */ + async _loadImageInfo() { + await this._super(...arguments); + const img = this._getImg(); + if (img.dataset.shape) { + if (img.dataset.mimetype !== "image/svg+xml") { + img.dataset.originalMimetype = img.dataset.mimetype; + } + if (!this._isImageSupportedForProcessing(img)) { + delete img.dataset.shape; + delete img.dataset.shapeColors; + delete img.dataset.fileName; + delete img.dataset.originalMimetype; + delete img.dataset.shapeFlip; + delete img.dataset.shapeRotate; + delete img.dataset.hoverEffect; + delete img.dataset.hoverEffectColor; + delete img.dataset.hoverEffectStrokeWidth; + delete img.dataset.hoverEffectIntensity; + img.classList.remove("o_animate_on_hover"); + delete img.dataset.shapeAnimationSpeed; + return; + } + if (img.dataset.mimetype !== "image/svg+xml") { + // Image data-mimetype should be changed to SVG since + // loadImageInfo() will set the original attachment mimetype on + // it. + img.dataset.mimetype = "image/svg+xml"; + } + } + }, + /** + * @private + */ + async _reapplyCurrentShape() { + const img = this._getImg(); + if (img.dataset.shape) { + await this._loadShape(img.dataset.shape); + await this._applyShapeAndColors(true, (img.dataset.shapeColors && img.dataset.shapeColors.split(';'))); + img.classList.add("o_modified_image_to_save"); + } + }, + /** + * @override + */ + _isImageProcessingWidget(widgetName, params) { + if (widgetName === 'shape_img_opt') { + return !isGif(this._getImageMimetype(this._getImg())); + } + return this._super(...arguments); + }, + /** + * Flips the image shape (vertically or/and horizontally). + * + * @private + * @param {string} flipValue image shape flip value + */ + async _setImgShapeFlip(flipValue) { + const imgEl = this._getImg(); + const currentFlipValue = imgEl.dataset.shapeFlip || ""; + const newFlipValue = currentFlipValue.includes(flipValue) + ? currentFlipValue.replace(flipValue, "") + : currentFlipValue + flipValue; + if (newFlipValue) { + imgEl.dataset.shapeFlip = newFlipValue === "yx" ? "xy" : newFlipValue; + } else { + delete imgEl.dataset.shapeFlip; + } + await this._applyShapeAndColors(true, imgEl.dataset.shapeColors?.split(";")); + imgEl.classList.add("o_modified_image_to_save"); + }, + /** + * Rotates the image shape 90 degrees. + * + * @private + * @param {integer} rotation rotation value + */ + async _setImgShapeRotate(rotation) { + const imgEl = this._getImg(); + const currentRotateValue = parseInt(imgEl.dataset.shapeRotate) || 0; + const newRotateValue = (currentRotateValue + rotation + 360) % 360; + if (newRotateValue) { + imgEl.dataset.shapeRotate = newRotateValue; + } else { + delete imgEl.dataset.shapeRotate; + } + await this._applyShapeAndColors(true, imgEl.dataset.shapeColors?.split(";")); + imgEl.classList.add("o_modified_image_to_save"); + }, + /** + * Checks if the shape is in the "devices" category. + * + * @private + * @returns {boolean} + */ + _isDeviceShape() { + const imgEl = this._getImg(); + const shapeName = imgEl.dataset.shape; + if (!shapeName) { + return false; + } + const shapeCategory = imgEl.dataset.shape.split("/")[1]; + return shapeCategory === "devices"; + }, + /** + * Checks if the shape is transformable. + * + * @private + * @returns {boolean} + */ + _isTransformableShape() { + const shapeImgWidget = this._requestUserValueWidgets("shape_img_opt")[0]; + return (shapeImgWidget && !shapeImgWidget.getMethodsParams().noTransform) && !this._isDeviceShape(); + }, + /** + * Checks if the shape is in animated. + * + * @private + * @returns {boolean} + */ + _isAnimatedShape() { + const shapeImgWidget = this._requestUserValueWidgets("shape_img_opt")[0]; + return shapeImgWidget?.getMethodsParams().animated; + }, + /** + * Checks if the shape can have a hover effect. + * + * @private + * @returns {boolean} + */ + _canHaveHoverEffect() { + return !this._isDeviceShape() && !this._isAnimatedShape() && this._isImageSupportedForShapes(); + }, + /** + * Adds hover effect to the SVG. + * + * @private + * @param {HTMLElement} svgEl + * @param {HTMLImageElement} [img] img element + */ + async _addImageShapeHoverEffect(svgEl, img) { + let rgba = null; + let rbg = null; + let opacity = null; + // Add the required parts for the hover effects to the SVG. + const hoverEffectName = img.dataset.hoverEffect; + if (!this.hoverEffectsSvg) { + this.hoverEffectsSvg = await this._getHoverEffects(); + } + const hoverEffectEls = this.hoverEffectsSvg.querySelectorAll(`#${hoverEffectName} > *`); + hoverEffectEls.forEach(hoverEffectEl => { + svgEl.appendChild(hoverEffectEl.cloneNode(true)); + }); + // Modifies the svg according to the chosen hover effect and the value + // of the options. + const animateEl = svgEl.querySelector("animate"); + const animateTransformEls = svgEl.querySelectorAll("animateTransform"); + const animateElValues = animateEl?.getAttribute("values"); + let animateTransformElValues = animateTransformEls[0]?.getAttribute("values"); + if (img.dataset.hoverEffectColor) { + rgba = convertCSSColorToRgba(img.dataset.hoverEffectColor); + rbg = `rgb(${rgba.red},${rgba.green},${rgba.blue})`; + opacity = rgba.opacity / 100; + if (!["outline", "image_mirror_blur"].includes(hoverEffectName)) { + svgEl.querySelector('[fill="hover_effect_color"]').setAttribute("fill", rbg); + animateEl.setAttribute("values", animateElValues.replace("hover_effect_opacity", opacity)); + } + } + switch (hoverEffectName) { + case "outline": { + svgEl.querySelector('[stroke="hover_effect_color"]').setAttribute("stroke", rbg); + svgEl.querySelector('[stroke-opacity="hover_effect_opacity"]').setAttribute("stroke-opacity", opacity); + // The stroke width needs to be multiplied by two because half + // of the stroke is invisible since it is centered on the path. + const strokeWidth = parseInt(img.dataset.hoverEffectStrokeWidth) * 2; + animateEl.setAttribute("values", animateElValues.replace("hover_effect_stroke_width", strokeWidth)); + break; + } + case "image_zoom_in": + case "image_zoom_out": + case "dolly_zoom": { + const imageEl = svgEl.querySelector("image"); + const clipPathEl = svgEl.querySelector("#clip-path"); + imageEl.setAttribute("id", "shapeImage"); + // Modify the SVG so that the clip-path is not zoomed when the + // image is zoomed. + imageEl.setAttribute("style", "transform-origin: center; width: 100%; height: 100%"); + imageEl.setAttribute("preserveAspectRatio", "none"); + svgEl.setAttribute("viewBox", "0 0 1 1"); + svgEl.setAttribute("preserveAspectRatio", "none"); + clipPathEl.setAttribute("clipPathUnits", "userSpaceOnUse"); + const clipPathValue = imageEl.getAttribute("clip-path"); + imageEl.removeAttribute("clip-path"); + const gEl = document.createElementNS("http://www.w3.org/2000/svg", "g"); + gEl.setAttribute("clip-path", clipPathValue); + imageEl.parentNode.replaceChild(gEl, imageEl); + gEl.appendChild(imageEl); + let zoomValue = 1.01 + parseInt(img.dataset.hoverEffectIntensity) / 200; + animateTransformEls[0].setAttribute("values", animateTransformElValues.replace("hover_effect_zoom", zoomValue)); + if (hoverEffectName === "image_zoom_out") { + // Set zoom intensity for the image. + const styleAttr = svgEl.querySelector("style"); + styleAttr.textContent = styleAttr.textContent.replace("hover_effect_zoom", zoomValue); + } + if (hoverEffectName === "dolly_zoom") { + clipPathEl.setAttribute("style", "transform-origin: center;"); + // Set zoom intensity for clip-path and overlay. + zoomValue = 0.99 - parseInt(img.dataset.hoverEffectIntensity) / 2000; + animateTransformEls.forEach((animateTransformEl, index) => { + if (index > 0) { + animateTransformElValues = animateTransformEl.getAttribute("values"); + animateTransformEl.setAttribute("values", animateTransformElValues.replace("hover_effect_zoom", zoomValue)); + } + }); + } + break; + } + case "image_mirror_blur": { + const imageEl = svgEl.querySelector("image"); + imageEl.setAttribute('id', 'shapeImage'); + imageEl.setAttribute('style', 'transform-origin: center;'); + const imageMirrorEl = imageEl.cloneNode(); + imageMirrorEl.setAttribute("id", 'shapeImageMirror'); + imageMirrorEl.setAttribute("filter", "url(#blurFilter)"); + imageEl.insertAdjacentElement("beforebegin", imageMirrorEl); + const zoomValue = 0.99 - parseInt(img.dataset.hoverEffectIntensity) / 200; + animateTransformEls[0].setAttribute("values", animateTransformElValues.replace("hover_effect_zoom", zoomValue)); + break; + } + } + }, + /** + * Gets the hover effects list. + * + * @private + * @returns {HTMLElement} + */ + _getHoverEffects() { + const hoverEffectsURL = "/website/static/src/svg/hover_effects.svg"; + return fetch(hoverEffectsURL) + .then(response => response.text()) + .then(text => { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(text, "text/xml"); + return xmlDoc.getElementsByTagName("svg")[0]; + }); + }, + /** + * Disables the hover effect on the image. + * + * @private + */ + async _disableHoverEffect() { + const imgEl = this._getImg(); + const shapeName = imgEl.dataset.shape?.split("/")[2]; + delete imgEl.dataset.hoverEffect; + delete imgEl.dataset.hoverEffectColor; + delete imgEl.dataset.hoverEffectStrokeWidth; + delete imgEl.dataset.hoverEffectIntensity; + await this._applyOptions(); + // If "Square" shape, remove it, it doesn't make sense to keep it + // without hover effect. + if (shapeName === "geo_square") { + this._requestUserValueWidgets("remove_img_shape_opt")[0].enable(); + } + }, + /** + * @override + */ + async _select(previewMode, widget) { + await this._super(...arguments); + // This is a special case where we need to override the "_select" + // function in order to trigger mouse events for hover effects on the + // images when previewing the options. This is done here because if it + // was done in one of the widget methods, the animation would be + // canceled when "_refreshPublicWidgets" is executed in the "_super" + if (widget.$el[0].closest("#o_hover_effects_options")) { + const hasSetImgShapeHoverEffectMethod = widget.getMethodsNames().includes("setImgShapeHoverEffect"); + // We trigger the animation when preview mode is "false", except for + // the "setImgShapeHoverEffect" option, where we trigger it when + // preview mode is "true". + if (previewMode === hasSetImgShapeHoverEffectMethod) { + this.$target[0].dispatchEvent(new Event("mouseover")); + this.hoverTimeoutId = setTimeout(() => { + this.$target[0].dispatchEvent(new Event("mouseout")); + }, 700); + } else if (previewMode === "reset") { + clearTimeout(this.hoverTimeoutId); + } + } + }, + /** + * Checks if a shape can be applied on the target. + * + * @private + * @returns {boolean} + */ + _isImageSupportedForShapes() { + const imgEl = this._getImg(); + return imgEl.dataset.originalId && this._isImageSupportedForProcessing(imgEl); + }, + + //-------------------------------------------------------------------------- + // 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); + }, + + //-------------------------------------------------------------------------- + // 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(); + // In the case of a parallax, the background of the snippet is actually + // set on a child and should be focused here. This is necessary + // because, at this point, the $target has not yet been updated in the + // notify() method ("option_update" event), although the event is + // properly fired from the parallax. + const targetEl = this.$target[0].classList.contains("oe_img_bg") + ? this.$target[0] : this.$target[0].querySelector(":scope > .s_parallax_bg.oe_img_bg"); + if (targetEl) { + Object.entries(targetEl.dataset).filter(([key]) => + isBackgroundImageAttribute(key)).forEach(([key, value]) => { + this.img.dataset[key] = value; + }); + const src = getBgImageURL(targetEl); + // 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 + */ + _relocateWeightEl() { + this.trigger_up('option_update', { + optionNames: ['BackgroundImage'], + name: 'add_size_indicator', + data: this.$weight, + }); + }, + /** + * @override + */ + _applyImage(img) { + const parts = backgroundImageCssToParts(this.$target.css('background-image')); + parts.url = `url('${img.getAttribute('src')}')`; + const combined = backgroundImagePartsToCss(parts); + this.$target.css('background-image', combined); + // Apply modification on the DOM HTML element that is currently being + // modified. + this.$target[0].classList.add("o_modified_image_to_save"); + // First delete the data attributes relative to the image background + // from the target as a data attribute could have been be removed (ex: + // glFilter). + for (const attribute in this.$target[0].dataset) { + if (isBackgroundImageAttribute(attribute)) { + delete this.$target[0].dataset[attribute]; + } + } + Object.entries(img.dataset).forEach(([key, value]) => { + this.$target[0].dataset[key] = value; + }); + this.$target[0].dataset.bgSrc = img.getAttribute("src"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Reloads image data when the background is changed. + * + * @private + */ + async _onBackgroundChanged(ev, previewMode) { + ev.stopPropagation(); + if (!previewMode) { + this.trigger_up('snippet_edition_request', {exec: async () => { + await this._autoOptimizeImage(); + }}); + } + }, +}); + +registry.BackgroundToggler = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Toggles background image on or off. + * + * @see this.selectClass for parameters + */ + toggleBgImage(previewMode, widgetValue, params) { + if (!widgetValue) { + this.$target.find('> .o_we_bg_filter').remove(); + // 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(); + }, + /** + * Sets a color filter. + * + * @see this.selectClass for parameters + */ + async selectFilterColor(previewMode, widgetValue, params) { + // Find the filter element. + let filterEl = this.$target[0].querySelector(':scope > .o_we_bg_filter'); + + // If the filter would be transparent, remove it / don't create it. + const rgba = widgetValue && convertCSSColorToRgba(widgetValue); + if (!widgetValue || rgba && rgba.opacity < 0.001) { + if (filterEl) { + filterEl.remove(); + } + return; + } + + // Create the filter if necessary. + if (!filterEl) { + filterEl = document.createElement('div'); + filterEl.classList.add('o_we_bg_filter'); + const lastBackgroundEl = this._getLastPreFilterLayerElement(); + if (lastBackgroundEl) { + $(lastBackgroundEl).after(filterEl); + } else { + this.$target.prepend(filterEl); + } + } + + // Apply the color on the filter. + const obj = createPropertyProxy(this, '$target', $(filterEl)); + params.cssProperty = 'background-color'; + return this.selectStyle.call(obj, previewMode, widgetValue, params); + }, + + //-------------------------------------------------------------------------- + // 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 'toggleBgShape': { + const [shapeWidget] = this._requestUserValueWidgets('bg_shape_opt'); + const shapeOption = shapeWidget.getParent(); + return !!shapeOption._computeWidgetState('shape', shapeWidget.getMethodsParams('shape')); + } + case 'selectFilterColor': { + const filterEl = this.$target[0].querySelector(':scope > .o_we_bg_filter'); + if (!filterEl) { + return ''; + } + const obj = createPropertyProxy(this, '$target', $(filterEl)); + params.cssProperty = 'background-color'; + return this._computeWidgetState.call(obj, 'selectStyle', params); + } + } + return this._super(...arguments); + }, + /** + * @private + */ + _getLastPreFilterLayerElement() { + return null; + }, +}); + +/** + * 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._setBackground(this.previousSrc); + return; + } + const newURL = new URL(currentSrc, window.location.origin); + newURL.searchParams.set(params.colorName, normalizeColor(widgetValue)); + const src = newURL.pathname + newURL.search; + await loadImage(src); + this._setBackground(src); + if (!previewMode) { + this.previousSrc = src; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + notify(name, data) { + if (name === 'add_size_indicator') { + this._requestUserValueWidgets('bg_image_opt')[0].$el.after(data); + } else { + this._super(...arguments); + } + }, + /** + * @override + */ + setTarget: function () { + // When we change the target of this option we need to transfer the + // background-image and the dataset information relative to this image + // from the old target to the new one. + const oldBgURL = getBgImageURL(this.$target); + const isModifiedImage = this.$target[0].classList.contains("o_modified_image_to_save"); + const filteredOldDataset = Object.entries(this.$target[0].dataset).filter(([key]) => { + return isBackgroundImageAttribute(key); + }); + // Delete the dataset information relative to the background-image of + // the old target. + filteredOldDataset.forEach(([key]) => { + delete this.$target[0].dataset[key]; + }); + // It is important to delete ".o_modified_image_to_save" from the old + // target as its image source will be deleted. + this.$target[0].classList.remove("o_modified_image_to_save"); + this._setBackground(''); + this._super(...arguments); + if (oldBgURL) { + this._setBackground(oldBgURL); + filteredOldDataset.forEach(([key, value]) => { + this.$target[0].dataset[key] = value; + }); + this.$target[0].classList.toggle("o_modified_image_to_save", isModifiedImage); + } + + // TODO should be automatic for all options as equal to the start method + this.__customImageSrc = getBgImageURL(this.$target[0]); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'background': + return getBgImageURL(this.$target[0]); + case 'dynamicColor': + return new URL(getBgImageURL(this.$target[0]), window.location.origin).searchParams.get(params.colorName); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if ('colorName' in params) { + const src = new URL(getBgImageURL(this.$target[0]), window.location.origin); + return src.searchParams.has(params.colorName); + } else if (widgetName === 'main_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) { + const parts = backgroundImageCssToParts(this.$target.css('background-image')); + if (backgroundURL) { + parts.url = `url('${backgroundURL}')`; + this.$target.addClass('oe_img_bg o_bg_img_center'); + } else { + delete parts.url; + this.$target[0].classList.remove( + "oe_img_bg", + "o_bg_img_center", + "o_modified_image_to_save", + ); + } + const combined = backgroundImagePartsToCss(parts); + // We use selectStyle so that if when a background image is removed the + // remaining image matches the o_cc's gradient background, it can be + // removed too. + this.selectStyle(false, combined, { + cssProperty: 'background-image', + }); + }, +}); + +/** + * Handles background shapes. + */ +registry.BackgroundShape = SnippetOptionWidget.extend({ + /** + * @override + */ + updateUI({assetsChanged} = {}) { + if (this.rerender || assetsChanged) { + this.rerender = false; + return this._rerenderXML(); + } + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt() { + // Flip classes should no longer be used but are still present in some + // theme snippets. + if (this.$target[0].querySelector('.o_we_flip_x, .o_we_flip_y')) { + this._handlePreviewState(false, () => { + return {flip: this._getShapeData().flip}; + }); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Sets the current background shape. + * + * @see this.selectClass for params + */ + shape(previewMode, widgetValue, params) { + this._handlePreviewState(previewMode, () => { + return { + shape: widgetValue, + colors: this._getImplicitColors(widgetValue, this._getShapeData().colors), + flip: [], + animated: params.animated, + shapeAnimationSpeed: this._getShapeData().shapeAnimationSpeed, + }; + }); + }, + /** + * 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'); + }, + /** + * Shows/Hides the shape on mobile. + * + * @see this.selectClass for params + */ + showOnMobile(previewMode, widgetValue, params) { + this._handlePreviewState(previewMode, () => { + return {showOnMobile: !this._getShapeData().showOnMobile}; + }); + }, + /** + * Sets the speed of the animation of a background shape. + * + * @see this.selectClass for params + */ + setBgShapeAnimationSpeed(previewMode, widgetValue, params) { + this._handlePreviewState(previewMode, () => { + return { shapeAnimationSpeed: widgetValue }; + }); + }, + + //-------------------------------------------------------------------------- + // 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'); + } + case 'showOnMobile': { + return this._getShapeData().showOnMobile; + } + case "setBgShapeAnimationSpeed": { + return this._getShapeData().shapeAnimationSpeed; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === "bg_shape_anim_speed_opt") { + const bgShapeWidget = this._requestUserValueWidgets("bg_shape_opt")[0]; + return bgShapeWidget.getMethodsParams().animated === "true"; + } + return this._super(...arguments); + }, + /** + * @override + */ + _renderCustomXML(uiFragment) { + Object.keys(this._getDefaultColors()).map(colorName => { + uiFragment.querySelector('[data-name="colors"]') + .prepend($(``)[0]); + }); + + // Inventory shape URLs per class. + const style = window.getComputedStyle(this.$target[0]); + const palette = [1, 2, 3, 4, 5].map(n => style.getPropertyValue(`--o-cc${n}-bg`)).join(); + if (palette !== this._lastShapePalette) { + this._lastShapePalette = palette; + this._shapeBackgroundImagePerClass = {}; + for (const styleSheet of this.$target[0].ownerDocument.styleSheets) { + if (styleSheet.href && new URL(styleSheet.href).host !== location.host) { + // In some browsers, if a stylesheet is loaded from a different domain + // accessing cssRules results in a SecurityError. + continue; + } + for (const rule of [...styleSheet.cssRules]) { + if (rule.selectorText && rule.selectorText.startsWith(".o_we_shape.")) { + this._shapeBackgroundImagePerClass[rule.selectorText] = rule.style.backgroundImage; + } + } + } + } + + 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); + + if (btn.dataset.animated) { + _addAnimatedShapeLabel(btnContent); + } + + const {shape} = btn.dataset; + const shapeEl = btnContent.querySelector('.o_we_shape'); + const shapeClassName = `o_${shape.replace(/\//g, '_')}`; + shapeEl.classList.add(shapeClassName); + // Match current palette. + const shapeBackgroundImage = this._shapeBackgroundImagePerClass[`.o_we_shape.${shapeClassName}`]; + shapeEl.style.setProperty("background-image", shapeBackgroundImage); + btn.append(btnContent); + }); + return uiFragment; + }, + /** + * 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]}; + }); + }, + /** + * Inserts or removes the given container at the right position in the + * document. + * + * @param {HTMLElement} [newContainer] container to insert, null to remove + */ + _insertShapeContainer(newContainer) { + const target = this.$target[0]; + + const shapeContainer = target.querySelector(':scope > .o_we_shape'); + if (shapeContainer) { + this._removeShapeEl(shapeContainer); + } + if (newContainer) { + const preShapeLayerElement = this._getLastPreShapeLayerElement(); + if (preShapeLayerElement) { + $(preShapeLayerElement).after(newContainer); + } else { + this.$target.prepend(newContainer); + } + } + return newContainer; + }, + /** + * Creates and inserts a container for the shape with the right classes. + * + * @param {string} shape the shape name for which to create a container + */ + _createShapeContainer(shape) { + const shapeContainer = this._insertShapeContainer(document.createElement('div')); + this.$target[0].style.position = 'relative'; + shapeContainer.className = `o_we_shape o_${shape.replace(/\//g, '_')}`; + return shapeContainer; + }, + /** + * 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]; + + let changedShape = false; + if (previewMode === 'reset') { + this._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 = [], animated = 'false', showOnMobile, shapeAnimationSpeed} = json ? JSON.parse(json) : {}; + let shapeContainer = target.querySelector(':scope > .o_we_shape'); + if (!shape) { + return this._insertShapeContainer(null); + } + // When changing shape we want to reset the shape container (for transparency color) + if (changedShape) { + shapeContainer = this._createShapeContainer(shape); + } + // Compat: remove old flip classes as flipping is now done inside the svg + shapeContainer.classList.remove('o_we_flip_x', 'o_we_flip_y'); + + shapeContainer.classList.toggle('o_we_animated', animated === 'true'); + if (colors || flip.length || parseFloat(shapeAnimationSpeed) !== 0) { + // Custom colors/flip/speed, 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', ''); + } + shapeContainer.classList.toggle('o_shape_show_mobile', !!showOnMobile); + if (previewMode === false) { + this.prevShapeContainer = shapeContainer.cloneNode(true); + this.prevShape = target.dataset.oeShapeData; + } + }, + /** + * @private + * @param {HTMLElement} shapeEl + */ + _removeShapeEl(shapeEl) { + shapeEl.remove(); + }, + /** + * 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 defaultColors[colorName] && 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, shapeAnimationSpeed } = 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=${encodeURIComponent(flip.sort().join(''))}`); + } + if (Number(shapeAnimationSpeed)) { + searchParams.push(`shapeAnimationSpeed=${encodeURIComponent(shapeAnimationSpeed)}`); + } + return `/web_editor/shape/${encodeURIComponent(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($(target)), + flip: [], + showOnMobile: false, + shapeAnimationSpeed: "0", + }; + 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 + * @param {jQueryElement} [$target=this.$target] the target on which to read + * the shape data. + */ + _getDefaultColors($target = this.$target) { + const $shapeContainer = $target.find('> .o_we_shape') + .clone() + .addClass('d-none') + // Needs to be in document for bg-image class to take effect + .appendTo(this.$target[0].ownerDocument.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()); + }, + /** + * Returns the default colors for the a shape in the selector. + * + * @private + * @param {String} shapeId identifier of the shape + */ + _getShapeDefaultColors(shapeId) { + const $shapeContainer = this.$el.find(".o_we_bg_shape_menu we-button[data-shape='" + shapeId + "'] div.o_we_shape"); + const shapeContainer = $shapeContainer[0]; + const shapeSrc = shapeContainer && getBgImageURL(shapeContainer); + const url = new URL(shapeSrc, window.location.origin); + return Object.fromEntries(url.searchParams.entries()); + }, + /** + * Returns the implicit colors for the currently selected shape. + * + * The implicit colors are use upon shape selection. They are computed as: + * - the default colors + * - patched with each set of colors of previous siblings shape + * - patched with the colors of the previously selected shape + * - filtered to only keep the colors involved in the current shape + * + * @private + * @param {String} shape identifier of the selected shape + * @param {Object} previousColors colors of the shape before its replacement + */ + _getImplicitColors(shape, previousColors) { + const defaultColors = this._getShapeDefaultColors(shape); + let colors = previousColors || {}; + let sibling = this.$target[0].previousElementSibling; + while (sibling) { + colors = Object.assign(this._getShapeData(sibling).colors || {}, colors); + sibling = sibling.previousElementSibling; + } + const defaultKeys = Object.keys(defaultColors); + colors = Object.assign(defaultColors, colors); + return pick(colors, ...defaultKeys); + }, + /** + * 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; + }); + } + // If there is no previous sibling, if the previous sibling had the + // last shape selected or if the previous shape could not be found + // in the possible shapes, default to the first shape. ([0] being no + // shapes selected.) + if (!shapeToSelect) { + shapeToSelect = possibleShapes[1]; + } + // Only show on mobile by default if toggled from mobile view + const showOnMobile = weUtils.isMobileView(this.$target[0]); + this.trigger_up('snippet_edition_request', {exec: () => { + // options for shape will only be available after _toggleShape() returned + this._requestUserValueWidgets('bg_shape_opt')[0].enable(); + }}); + this._createShapeContainer(shapeToSelect); + return this._handlePreviewState(false, () => ( + { + shape: shapeToSelect, + colors: this._getImplicitColors(shapeToSelect), + showOnMobile, + } + )); + } + }, +}); + +/** + * Handles the edition of snippets' background image position. + */ +registry.BackgroundPosition = SnippetOptionWidget.extend({ + /** + * @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', widgetValue !== 'repeat-pattern' ? '' : '100px'); + }, + /** + * 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, + }; + // Make sure the element is in a visible area. + const rect = this.$target[0].getBoundingClientRect(); + const viewportTop = $(window).scrollTop(); + const viewportBottom = viewportTop + $(window).height(); + const visibleHeight = rect.top < viewportTop + ? Math.max(0, Math.min(viewportBottom, rect.bottom) - viewportTop) // Starts above + : rect.top < viewportBottom + ? Math.min(viewportBottom, rect.bottom) - rect.top // Starts inside + : 0; // Starts after + if (visibleHeight < 200) { + await dom.scrollTo(this.$target[0], {extraOffset: 50}); + } + 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 = $(renderToElement('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 = $(this.ownerDocument.body).find("#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 = Math.max(0, $(window).scrollTop() - this.$target.offset().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'); + if (this.$bgDragger) { + this.$bgDragger.tooltip('dispose'); + } + 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.$target[0].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').length) { + 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.options.wysiwyg.odooEditor.observerUnactive('_markColorLevel'); + this.$target.addClass('o_colored_level'); + this.options.wysiwyg.odooEditor.observerActive('_markColorLevel'); + }, +}); + +registry.ContainerWidth = SnippetOptionWidget.extend({ + /** + * @override + */ + cleanForSave: function () { + this.$target.removeClass('o_container_preview'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + selectClass: async function (previewMode, widgetValue, params) { + await this._super(...arguments); + if (previewMode === 'reset') { + this.$target.removeClass('o_container_preview'); + } else if (previewMode) { + this.$target.addClass('o_container_preview'); + } + this.trigger_up('option_update', { + optionName: 'StepsConnector', + name: 'change_container_width', + }); + }, +}); + +/** + * 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({ + init() { + this._super(...arguments); + this.orm = this.bindService("orm"); + }, + + /** + * @override + */ + async willStart() { + const {oeMany2oneModel, oeMany2oneId} = this.$target[0].dataset; + this.fields = ['name', 'display_name']; + return Promise.all([ + this._super(...arguments), + this.orm.read(oeMany2oneModel, [parseInt(oeMany2oneId)], this.fields).then(([initialRecord]) => { + this.initialRecord = initialRecord; + }), + ]); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for params + */ + async changeRecord(previewMode, widgetValue, params) { + const target = this.$target[0]; + if (previewMode === 'reset') { + // Have to set the jQ data because it's used to update the record in other + // parts of the page, but have to set the dataset because used for saving. + this.$target.data('oeMany2oneId', this.prevId); + target.dataset.oeMany2oneId = this.prevId; + this.$target.empty().append(this.$prevContents); + return this._rerenderContacts(this.prevId, this.prevRecordName); + } + + const record = JSON.parse(params.recordData); + if (previewMode === true) { + this.prevId = parseInt(target.dataset.oeMany2oneId); + this.$prevContents = this.$target.contents(); + this.prevRecordName = this.prevRecordName || this.initialRecord.name; + } + + this.$target.data('oeMany2oneId', record.id); + target.dataset.oeMany2oneId = record.id; + + if (target.dataset.oeType !== 'contact') { + target.textContent = record.name; + } + await this._rerenderContacts(record.id, record.name); + + if (previewMode === false) { + this.prevId = record.id; + this.$prevContents = this.$target.contents(); + this.prevRecordName = record.name; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'changeRecord') { + return this.$target[0].dataset.oeMany2oneId; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + const many2oneWidget = document.createElement('we-many2one'); + many2oneWidget.dataset.changeRecord = ''; + + const model = this.$target[0].dataset.oeMany2oneModel; + const [{name: modelName}] = await this.orm.searchRead("ir.model", [['model', '=', model]], ['name']); + many2oneWidget.setAttribute('String', modelName); + many2oneWidget.dataset.model = model; + many2oneWidget.dataset.fields = JSON.stringify(this.fields); + uiFragment.appendChild(many2oneWidget); + }, + /** + * @private + */ + async _rerenderContacts(contactId, defaultText) { + // Rerender this same field in other places in the page (with different + // contact-options). Many2ones with the same contact options will just + // copy the HTML of the current m2o on content_changed. Not sure why we + // only do this for contacts, or why we do this here instead of in the + // wysiwyg like we do for replacing text on content_changed + const selector = [ + `[data-oe-model="${this.$target.data('oe-model')}"]`, + `[data-oe-id="${this.$target.data('oe-id')}"]`, + `[data-oe-field="${this.$target.data('oe-field')}"]`, + `[data-oe-contact-options!='${this.$target[0].dataset.oeContactOptions}']`, + ].join(''); + let $toRerender = $(selector); + if (this.$target[0].dataset.oeType === 'contact') { + $toRerender = $toRerender.add(this.$target); + } + await Promise.all($toRerender + .attr('data-oe-many2one-id', contactId).data('oe-many2one-id', contactId) + .map(async (i, node) => { + if (node.dataset.oeType === 'contact') { + const html = await this.orm.call( + "ir.qweb.field.contact", + "get_record_to_html", + [[contactId]], + {options: JSON.parse(node.dataset.oeContactOptions)} + ); + $(node).html(html); + } else { + node.textContent = defaultText; + } + })); + }, +}); +/** + * Allows to display a warning message on outdated snippets. + */ +registry.VersionControl = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Replaces an outdated snippet by its new version. + */ + async replaceSnippet() { + // Getting the new block version. + let newBlockEl; + this.trigger_up("find_snippet_template", { + snippet: this.$target[0], + callback: (snippet) => { + newBlockEl = snippet.baseBody.cloneNode(true); + }, + }); + // Replacing the block. + this.options.wysiwyg.odooEditor.historyPauseSteps(); + this.$target[0].classList.add("d-none"); // Hiding the block to replace it smoothly. + this.$target[0].insertAdjacentElement("beforebegin", newBlockEl); + // Initializing the new block as if it was dropped: the mutex needs to + // be free for that so we wait for it first. + this.options.wysiwyg.waitForEmptyMutexAction().then(async () => { + await new Promise((resolve) => { + this.options.wysiwyg.snippetsMenuBus.trigger("CALL_POST_SNIPPET_DROP", { + $snippet: $(newBlockEl), + onSuccess: resolve, + }); + }); + await new Promise(resolve => { + this.trigger_up("remove_snippet", + {$snippet: this.$target, onSuccess: resolve, shouldRecordUndo: false} + ); + }); + this.options.wysiwyg.odooEditor.historyUnpauseSteps(); + newBlockEl.classList.remove("oe_snippet_body"); + this.options.wysiwyg.odooEditor.historyStep(); + }); + }, + /** + * Allows to still access the options of an outdated block, despite the + * warning. + */ + discardAlert() { + const alertEl = this.$el[0].querySelector("we-alert"); + const optionsSectionEl = this.$overlay.data("$optionsSection")[0]; + alertEl.remove(); + optionsSectionEl.classList.remove("o_we_outdated_block_options"); + // Preventing the alert to reappear at each render. + controlledSnippets.add(this.$target[0].dataset.snippet); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _renderCustomXML(uiFragment) { + const snippetName = this.$target[0].dataset.snippet; + // Do not display the alert if it was previously discarded. + if (controlledSnippets.has(snippetName)) { + return; + } + this.trigger_up("get_snippet_versions", { + snippetName: snippetName, + onSuccess: snippetVersions => { + const isUpToDate = snippetVersions && ["vjs", "vcss", "vxml"].every(key => this.$target[0].dataset[key] === snippetVersions[key]); + if (!isUpToDate) { + uiFragment.prepend(renderToElement("web_editor.outdated_block_message")); + // Hide the other options, to only have the alert displayed. + const optionsSectionEl = this.$overlay.data("$optionsSection")[0]; + optionsSectionEl.classList.add("o_we_outdated_block_options"); + } + }, + }); + }, +}); + +/** + * Handle the save of a snippet as a template that can be reused later + */ +registry.SnippetSave = SnippetOptionWidget.extend({ + isTopOption: true, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + saveSnippet: function (previewMode, widgetValue, params) { + return new Promise(resolve => { + this.dialog.add(ConfirmationDialog, { + body: _t("To save a snippet, we need to save all your previous modifications and reload the page."), + cancel: () => resolve(false), + confirmLabel: _t("Save and Reload"), + confirm: () => { + const isButton = this.$target[0].matches("a.btn"); + const snippetKey = !isButton ? this.$target[0].dataset.snippet : "s_button"; + 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, + invalidateSnippetCache: true, + onSuccess: async () => { + const defaultSnippetName = !isButton + ? _t("Custom %s", this.data.snippetName) + : _t("Custom Button"); + const targetCopyEl = this.$target[0].cloneNode(true); + targetCopyEl.classList.add('s_custom_snippet'); + delete targetCopyEl.dataset.name; + if (isButton) { + targetCopyEl.classList.remove("mb-2"); + targetCopyEl.classList.add("o_snippet_drop_in_only", "s_custom_button"); + } + // 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 + + // Get editable parent TODO find proper method to get it directly + let editableParentEl; + for (const parentEl of this.options.getContentEditableAreas()) { + if (parentEl.contains(this.$target[0])) { + editableParentEl = parentEl; + break; + } + } + context['model'] = editableParentEl.dataset.oeModel; + context['field'] = editableParentEl.dataset.oeField; + context['resId'] = editableParentEl.dataset.oeId; + await rpc(`/web/dataset/call_kw/ir.ui.view/save_snippet`, { + model: "ir.ui.view", + method: "save_snippet", + args: [], + kwargs: { + 'name': defaultSnippetName, + 'arch': targetCopyEl.outerHTML, + 'template_key': this.options.snippets, + 'snippet_key': snippetKey, + 'thumbnail_url': thumbnailURL, + 'context': context, + }, + }); + }, + }); + resolve(true); + }, + }); + }); + }, +}); + +/** + * 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(params.colorName, 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(params.colorName); + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if ('colorName' in params) { + return new URL(this.$target[0].src, window.location.origin).searchParams.get(params.colorName); + } + 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(); + }, +}); + +/** + * Allows to handle snippets with a list of items. + */ +registry.MultipleItems = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + async addItem(previewMode, widgetValue, params) { + const $target = this.$(params.item); + const addBeforeItem = params.addBefore === 'true'; + if ($target.length) { + await new Promise(resolve => { + this.trigger_up('clone_snippet', { + $snippet: $target, + onSuccess: resolve, + }); + }); + if (addBeforeItem) { + $target.before($target.next()); + } + if (params.selectItem !== 'false') { + this.trigger_up('activate_snippet', { + $snippet: addBeforeItem ? $target.prev() : $target.next(), + }); + } + this._addItemCallback($target); + } + }, + /** + * @see this.selectClass for parameters + */ + async removeItem(previewMode, widgetValue, params) { + const $target = this.$(params.item); + if ($target.length) { + await new Promise(resolve => { + this.trigger_up('remove_snippet', { + $snippet: $target, + onSuccess: resolve, + }); + }); + this._removeItemCallback($target); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Allows to add behaviour when item added. + * + * @private + * @abstract + * @param {jQueryElement} $target + */ + _addItemCallback($target) {}, + /** + * @private + * @abstract + * @param {jQueryElement} $target + */ + _removeItemCallback($target) {}, +}); + +registry.SelectTemplate = SnippetOptionWidget.extend({ + custom_events: Object.assign({}, SnippetOptionWidget.prototype.custom_events, { + 'user_value_widget_opening': '_onWidgetOpening', + }), + + /** + * @constructor + */ + init() { + this._super(...arguments); + this.containerSelector = ''; + this.selectTemplateWidgetName = ''; + this.orm = this.bindService("orm"); + }, + /** + * @constructor + */ + async start() { + this.containerEl = this.containerSelector ? this.$target.find(this.containerSelector)[0] : this.$target[0]; + this._templates = {}; + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Changes the snippet layout. + * + * @see this.selectClass for parameters + */ + async selectTemplate(previewMode, widgetValue, params) { + await this._templatesLoading; + + if (previewMode === 'reset') { + if (!this.beforePreviewNodes) { + // FIXME should not be necessary: only needed because we have a + // strange 'reset' sent after a non-preview + return; + } + + // Empty the container and restore the original content + while (this.containerEl.lastChild) { + this.containerEl.removeChild(this.containerEl.lastChild); + } + for (const node of this.beforePreviewNodes) { + this.containerEl.appendChild(node); + } + this.beforePreviewNodes = null; + return; + } + + if (!this.beforePreviewNodes) { + // We are about the apply a template on non-previewed content, + // save that content's nodes. + this.beforePreviewNodes = [...this.containerEl.childNodes]; + } + // Empty the container and add the template content + while (this.containerEl.lastChild) { + this.containerEl.removeChild(this.containerEl.lastChild); + } + this.containerEl.insertAdjacentHTML('beforeend', this._templates[widgetValue]); + + if (!previewMode) { + // The original content to keep saved has to be retrieved just + // before the preview (if we save it now, we might miss other items + // added by other options or custo). + this.beforePreviewNodes = null; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Retrieves a template either from cache or through RPC. + * + * @private + * @param {string} xmlid + * @returns {string} + */ + async _getTemplate(xmlid) { + if (!this._templates[xmlid]) { + this._templates[xmlid] = await this.orm.call( + "ir.ui.view", + "render_public_asset", + [`${xmlid}`, {}], + { context: this.options.context } + ); + } + return this._templates[xmlid]; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onWidgetOpening(ev) { + if (this._templatesLoading || ev.target.getName() !== this.selectTemplateWidgetName) { + return; + } + const templateParams = ev.target.getMethodsParams('selectTemplate'); + const proms = templateParams.possibleValues.map(async xmlid => { + if (!xmlid) { + return; + } + // TODO should be better and retrieve all rendering in one RPC (but + // those ~10 RPC are only done once per edit mode if the option is + // opened, so I guess this is acceptable). + await this._getTemplate(xmlid); + }); + this._templatesLoading = Promise.all(proms); + }, +}); + +/* + * Abstract option to be extended by the Carousel and gallery options (through + * the "CarouselHandler" option) that handles all the common parts (reordering + * of elements). + */ +registry.GalleryHandler = SnippetOptionWidget.extend({ + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Handles reordering of items. + * + * @override + */ + notify(name, data) { + this._super(...arguments); + if (name === "reorder_items") { + const itemsEls = this._getItemsGallery(); + const oldPosition = itemsEls.indexOf(data.itemEl); + if (oldPosition === 0 && data.position === "prev") { + data.position = "last"; + } else if (oldPosition === itemsEls.length - 1 && data.position === "next") { + data.position = "first"; + } + itemsEls.splice(oldPosition, 1); + switch (data.position) { + case "first": + itemsEls.unshift(data.itemEl); + break; + case "prev": + itemsEls.splice(Math.max(oldPosition - 1, 0), 0, data.itemEl); + break; + case "next": + itemsEls.splice(oldPosition + 1, 0, data.itemEl); + break; + case "last": + itemsEls.push(data.itemEl); + break; + } + this._reorderItems(itemsEls, itemsEls.indexOf(data.itemEl)); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called to get the items of the gallery sorted by index if any (see + * gallery option) or by the order on the DOM otherwise. + * + * @abstract + * @returns {HTMLElement[]} + */ + _getItemsGallery() {}, + /** + * Called to reorder the items of the gallery. + * + * @abstract + * @param {HTMLElement[]} itemsEls - the items to reorder. + * @param {integer} newItemPosition - the new position of the moved items. + */ + _reorderItems(itemsEls, newItemPosition) {}, +}); + +/* + * Abstract option to be extended by the Carousel and gallery options that + * handles the update of the carousel indicator. + */ +registry.CarouselHandler = registry.GalleryHandler.extend({ + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Update the carousel indicator. + * + * @private + * @param {integer} position - the position of the indicator to activate on + * the carousel. + */ + _updateIndicatorAndActivateSnippet(position) { + const carouselEl = this.$target[0].classList.contains("carousel") ? this.$target[0] + : this.$target[0].querySelector(".carousel"); + carouselEl.classList.remove("slide"); + $(carouselEl).carousel(position); + for (const indicatorEl of this.$target[0].querySelectorAll(".carousel-indicators li")) { + indicatorEl.classList.remove("active"); + } + this.$target[0].querySelector(`.carousel-indicators li[data-bs-slide-to="${position}"]`) + .classList.add("active"); + this.trigger_up("activate_snippet", { + $snippet: $(this.$target[0].querySelector(".carousel-item.active img")), + ifInactiveOptions: true, + }); + carouselEl.classList.add("slide"); + }, +}); + + +export default { + SnippetOptionWidget: SnippetOptionWidget, + snippetOptionRegistry: registry, + + NULL_ID: NULL_ID, + UserValueWidget: UserValueWidget, + userValueWidgetsRegistry: userValueWidgetsRegistry, + UnitUserValueWidget: UnitUserValueWidget, + + addTitleAndAllowedAttributes: _addTitleAndAllowedAttributes, + buildElement: _buildElement, + buildTitleElement: _buildTitleElement, + buildRowElement: _buildRowElement, + buildCollapseElement: _buildCollapseElement, + + addAnimatedShapeLabel: _addAnimatedShapeLabel, + + // Other names for convenience + Class: SnippetOptionWidget, + registry: registry, + serviceCached, + clearServiceCache, + clearControlledSnippets, +}; diff --git a/addons/web_editor/static/src/js/editor/snippets.options.xml b/addons/web_editor/static/src/js/editor/snippets.options.xml new file mode 100644 index 0000000000000..645f4ac021c77 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/snippets.options.xml @@ -0,0 +1,864 @@ + + + + + + + + + + + + Test Class + + Checkbox + Test Class + + + Test Class 1 + Test Class 2 + Test Class 3 + Test Class 4 + Test Class 5 + + + Test Button Group + 6 + 7 + 8 + + + + + Show when test_class_1 + + Row + Button + + + Low to high + + + + High to low + + + + + + A wonderful WeList + + + + + + + + + + + + + + + + + + + + + + + + + + + + This block is outdated. + You might not be able to customize it anymore. + REPLACE BY NEW VERSION + You can still access the block options but it might be ineffective. + ACCESS OPTIONS ANYWAY + + + + + + + + Replace +
+ +
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+ + + + + + +
+ +
+
+
+ + + + +
+ + + + + + + + + + +
+
+
+ + + +
+ +
+
+
+ + + +
+ + + + + + + + + + + + +
+
+
+ + + +
+ + + +
+
+
+ + + +
+ + + + +
+
+
+ + + +
+ +
+
+
+ + + +
+ + + +
+
+
+ + + +
+ + Replace +
+
+
+ + + +
+ + + +
+ + +
+
+ +
+
+
+ + + +
+ +
+
+
+ + + +
+
+ + + + + + + + + + +
+ + + + + + + + + + + +
+
+ + + + + + +
+ + + + + + + +
+ + + + No more records + +
+ +
+
+ +
+ + + + + + + + + + + + Search to show more records +
+ Search more... +
+ + + + + Create + + + +
+
+ +
+ +
+ + + + + + +
+
+
+ + + +
+ + +
+
+
+
+ +
+
+
+ +
+
+
+
+
+ + + + + None + Blur + 1977 + Aden + Brannan + EarlyBird + Inkwell + Maven + Toaster + Walden + Valencia + Xpro + Custom + + + + Normal + overlay + screen + multiply + add + exclusion + darken + lighten + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Animated + + + + + + + + +
+
+ + + +
+
+
+
+ + + + + +
Shapes
+
+ + + + + +
+ Geometrics + + + + + + + + + + + + + + + Bold + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Airy & Zigs + + + + + + + + + + + + + + + + + + + + + + +
+
+ Wavy + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Block & Rainy + + + + + + + + + + + + + + + + + Floating Shapes + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + Cover + Repeat pattern + + + + + + + + + + + + + diff --git a/addons/web_editor/static/src/js/editor/snippets.registry.js b/addons/web_editor/static/src/js/editor/snippets.registry.js new file mode 100644 index 0000000000000..ab559a959bee9 --- /dev/null +++ b/addons/web_editor/static/src/js/editor/snippets.registry.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ +import { registry } from "@web/core/registry"; + + +export function registerOption(name, def, options) { + if (!def.module) { + def.module = "web_editor"; + } + return registry.category("snippet_options").add(name, def, options); +} + diff --git a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss index dfd47c071085a..69a20509229ec 100644 --- a/addons/web_editor/static/src/scss/wysiwyg_snippets.scss +++ b/addons/web_editor/static/src/scss/wysiwyg_snippets.scss @@ -1442,10 +1442,6 @@ display: inline-block; color: inherit; height: 100%; - - &.o_we_matrix_remove_col, &.o_we_matrix_remove_row { - display: none; - } } input { border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color; @@ -1685,22 +1681,22 @@ padding-right: .5em; font-family: FontAwesome; } + } - input { - flex: 1 1 auto; - color: inherit; - border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color; - border-radius: $o-we-sidebar-content-field-border-radius; - background-color: $o-we-sidebar-content-field-input-bg; - padding: 1px $o-we-sidebar-content-field-clickable-spacing; + :is(.o_we_m2o_search, .o_we_m2o_create) input { + flex: 1 1 auto; + color: inherit; + border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-border-color; + border-radius: $o-we-sidebar-content-field-border-radius; + background-color: $o-we-sidebar-content-field-input-bg; + padding: 1px $o-we-sidebar-content-field-clickable-spacing; - &:focus { - outline: none; - border-color: $o-we-sidebar-content-field-input-border-color; - } - &::placeholder { - color: $o-we-sidebar-content-field-control-item-color; - } + &:focus { + outline: none; + border-color: $o-we-sidebar-content-field-input-border-color; + } + &::placeholder { + color: $o-we-sidebar-content-field-control-item-color; } } @@ -1851,7 +1847,7 @@ margin-bottom: -($o-we-sidebar-content-field-spacing - $-inner-spacing); background-clip: padding-box; - > :first-child, .o_we_collapse_toggler { + > :first-child, .o_we_collapse_toggler:not(.o_we_user_value_widget) { margin-top: $-inner-spacing; } we-toggler.o_we_collapse_toggler { diff --git a/addons/web_editor/static/src/xml/snippets.xml b/addons/web_editor/static/src/xml/snippets.xml index 606e4af66f446..90536819c46a2 100644 --- a/addons/web_editor/static/src/xml/snippets.xml +++ b/addons/web_editor/static/src/xml/snippets.xml @@ -50,15 +50,6 @@ - - - This block is outdated. - You might not be able to customize it anymore. - REPLACE BY NEW VERSION - You can still access the block options but it might be ineffective. - ACCESS OPTIONS ANYWAY - -
@@ -119,7 +110,7 @@
-
+

Title

Text

Button @@ -216,7 +207,8 @@
-
+
+ @@ -259,4 +251,35 @@ + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
diff --git a/addons/web_editor/static/tests/test_utils.js b/addons/web_editor/static/tests/test_utils.js index fc6728bb6608a..f3e6da6116169 100644 --- a/addons/web_editor/static/tests/test_utils.js +++ b/addons/web_editor/static/tests/test_utils.js @@ -5,7 +5,7 @@ import testUtils from "@web/../tests/legacy_tests/helpers/test_utils"; import { patch } from "@web/core/utils/patch"; import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor"; import { Wysiwyg } from '@web_editor/js/wysiwyg/wysiwyg'; -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import { TABLE_ATTRIBUTES, TABLE_STYLES } from '@web_editor/js/backend/convert_inline'; export const COLOR_PICKER_TEMPLATE = ` diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml index 79f5c7a395c37..0d87b654e1918 100644 --- a/addons/web_editor/views/snippets.xml +++ b/addons/web_editor/views/snippets.xml @@ -397,35 +397,6 @@
-
- - - -
- - Replace -
- -
-
- - -
-
- -
- - - - - - - -
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index 2a90cf3065884..3453391af2284 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -95,7 +95,6 @@ 'views/snippets/s_dynamic_snippet.xml', 'views/snippets/s_dynamic_snippet_carousel.xml', 'views/snippets/s_embed_code.xml', - 'views/snippets/s_website_controller_page_listing_layout.xml', 'views/snippets/s_website_form.xml', 'views/snippets/s_searchbar.xml', 'views/snippets/s_button.xml', @@ -240,32 +239,57 @@ 'website/static/src/scss/website.wysiwyg.scss', 'website/static/src/scss/website.edit_mode.scss', 'website/static/src/js/editor/snippets.editor.js', + 'website/static/src/js/editor/snippets.options.legacy.js', 'website/static/src/js/editor/snippets.options.js', + 'website/static/src/js/editor/snippets.options.xml', + 'website/static/src/js/editor/snippets.registry.js', + 'website/static/src/js/editor/theme.options.xml', 'website/static/src/snippets/s_facebook_page/options.js', + 'website/static/src/snippets/s_facebook_page/options.xml', 'website/static/src/snippets/s_image/options.js', 'website/static/src/snippets/s_image_gallery/options.js', + 'website/static/src/snippets/s_image_gallery/options.xml', 'website/static/src/snippets/s_image_gallery/000.xml', 'website/static/src/snippets/s_instagram_page/options.js', 'website/static/src/snippets/s_card/001.xml', 'website/static/src/snippets/s_card/options.js', + 'website/static/src/snippets/s_card/options.xml', + 'website/static/src/snippets/s_instagram_page/options.xml', 'website/static/src/snippets/s_countdown/options.js', 'website/static/src/snippets/s_countdown/options.xml', + 'website/static/src/snippets/s_embed_code/options.xml', 'website/static/src/snippets/s_masonry_block/options.js', + 'website/static/src/snippets/s_masonry_block/options.xml', 'website/static/src/snippets/s_popup/options.js', 'website/static/src/snippets/s_product_catalog/options.js', + 'website/static/src/snippets/s_product_catalog/options.xml', 'website/static/src/snippets/s_chart/options.js', + 'website/static/src/snippets/s_chart/options.xml', 'website/static/src/snippets/s_rating/options.js', + 'website/static/src/snippets/s_rating/options.xml', 'website/static/src/snippets/s_tabs/options.js', + 'website/static/src/snippets/s_tabs/options.xml', 'website/static/src/snippets/s_progress_bar/options.js', + 'website/static/src/snippets/s_progress_bar/options.xml', + 'website/static/src/snippets/s_blockquote/options.js', + 'website/static/src/snippets/s_blockquote/options.xml', + 'website/static/src/snippets/s_showcase/options.js', 'website/static/src/snippets/s_table_of_content/options.js', 'website/static/src/snippets/s_timeline/options.js', + 'website/static/src/snippets/s_timeline/options.xml', 'website/static/src/snippets/s_media_list/options.js', + 'website/static/src/snippets/s_media_list/options.xml', 'website/static/src/snippets/s_google_map/options.js', 'website/static/src/snippets/s_map/options.js', + 'website/static/src/snippets/s_map/options.xml', 'website/static/src/snippets/s_dynamic_snippet/options.js', + 'website/static/src/snippets/s_dynamic_snippet/options.xml', 'website/static/src/snippets/s_dynamic_snippet_carousel/options.js', + 'website/static/src/snippets/s_dynamic_snippet_carousel/options.xml', 'website/static/src/snippets/s_website_controller_page_listing_layout/options.js', + 'website/static/src/snippets/s_website_controller_page_listing_layout/options.xml', 'website/static/src/snippets/s_website_form/options.js', + 'website/static/src/snippets/s_website_form/options.xml', 'website/static/src/js/form_editor_registry.js', 'website/static/src/js/send_mail_form.js', 'website/static/src/xml/website_form.xml', @@ -273,7 +297,9 @@ 'website/static/src/xml/website_form_editor.xml', 'website/static/src/snippets/s_searchbar/options.js', 'website/static/src/snippets/s_social_media/options.js', + 'website/static/src/snippets/s_social_media/options.xml', 'website/static/src/snippets/s_process_steps/options.js', + 'website/static/src/snippets/s_process_steps/options.xml', 'website/static/src/js/editor/widget_link.js', 'website/static/src/js/widgets/link_popover_widget.js', 'website/static/src/xml/website.cookies_bar.xml', diff --git a/addons/website/static/src/js/editor/snippets.editor.js b/addons/website/static/src/js/editor/snippets.editor.js index ed57677bafc22..2032b59616d1f 100644 --- a/addons/website/static/src/js/editor/snippets.editor.js +++ b/addons/website/static/src/js/editor/snippets.editor.js @@ -5,9 +5,9 @@ import { Dialog } from "@web/core/dialog/dialog"; import { useChildRef, useService } from "@web/core/utils/hooks"; import { user } from "@web/core/user"; import weSnippetEditor from "@web_editor/js/editor/snippets.editor"; -import wSnippetOptions from "@website/js/editor/snippets.options"; +import wLegacySnippetOptions from "@website/js/editor/snippets.options.legacy"; import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/utils/utils"; -import { Component, onMounted, onWillStart, useEffect, useRef, useState } from "@odoo/owl"; +import { Component, onMounted, onWillStart, useEffect, useRef, useState, useSubEnv } from "@odoo/owl"; import { throttleForAnimation } from "@web/core/utils/timing"; import { switchTextHighlight } from "@website/js/text_processing"; import { registry } from "@web/core/registry"; @@ -18,7 +18,7 @@ snippetsEditorRegistry.add("no_parent_editor_snippets", ["s_popup", "o_mega_menu const getDeepRange = OdooEditorLib.getDeepRange; const getTraversedNodes = OdooEditorLib.getTraversedNodes; -const FontFamilyPickerUserValueWidget = wSnippetOptions.FontFamilyPickerUserValueWidget; +const FontFamilyPickerUserValueWidget = wLegacySnippetOptions.FontFamilyPickerUserValueWidget; const ANIMATED_TEXT_SELECTOR = ".o_animated_text"; const HIGHLIGHTED_TEXT_SELECTOR = ".o_text_highlight"; @@ -58,6 +58,12 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu { * @override */ setup() { + useSubEnv({ + getSwitchableRelatedViews: (data) => this._onGetSwitchableRelatedViews.call(this, { data }), + gmapApiRequest: (data) => this._onGMapAPIRequest.call(this, { data }), + gmapApiKeyRequest: (data) => this._onGMapAPIKeyRequest.call(this, { data }), + reloadBundles: (data) => this._onReloadBundles.call(this, { data }), + }); super.setup(); this.notification = useService("notification"); this.dialog = useService("dialog"); @@ -224,6 +230,15 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu { FontFamilyPickerUserValueWidget.prototype.fontVariables = fontVariables; return super._computeSnippetTemplates(html); } + /** + * @override + */ + getOptions() { + const options = super.getOptions().filter(([optionID, option]) => { + return ["website", "web_editor"].includes(option.module); + }); + return options; + } /** * Depending of the demand, reconfigure they gmap key or configure it * if not already defined. @@ -352,7 +367,6 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu { * @param {string} gmapRequestEventName */ async _handleGMapRequest(ev, gmapRequestEventName) { - ev.stopPropagation(); const reconfigured = await this._configureGMapAPI({ alwaysReconfigure: ev.data.reconfigure, configureIfNecessary: ev.data.configureIfNecessary, @@ -665,6 +679,11 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu { this.props.setCSSVariables(this.el); oldSuccess(...args); }; + // TODO: @owl-options Definitely not correct but at least it pretends to work + ev.name = 'reload_bundles'; + this.options.wysiwyg._trigger_up(ev); + ev.data.onSuccess(); + this.env.activateSnippet(); for (const editor of this.snippetEditors) { if (!editor.$target[0].matches(excludeSelector)) { if (this._currentTab === this.tabs.THEME) { diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index 64d67b76e3fd9..3fc8769bad6cd 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -6,9 +6,10 @@ import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_d import { Dialog } from "@web/core/dialog/dialog"; import { rpc } from "@web/core/network/rpc"; import { user } from "@web/core/user"; -import { useChildRef } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; +import { useChildRef, useService } from "@web/core/utils/hooks"; import weUtils from "@web_editor/js/common/utils"; -import options from "@web_editor/js/editor/snippets.options"; +import options from "@web_editor/js/editor/snippets.options.legacy"; import { NavbarLinkPopoverWidget } from "@website/js/widgets/link_popover_widget"; import wUtils from "@website/js/utils"; import { @@ -35,15 +36,39 @@ import { drawTextHighlightSVG, } from "@website/js/text_processing"; -import { Component, markup, useEffect, useRef, useState } from "@odoo/owl"; +import { patch } from "@web/core/utils/patch"; +import { session } from "@web/session"; +import { Component, markup, onMounted, onWillUnmount, useEffect, useRef, useState } from "@odoo/owl"; -const InputUserValueWidget = options.userValueWidgetsRegistry['we-input']; -const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select']; -const Many2oneUserValueWidget = options.userValueWidgetsRegistry['we-many2one']; - -options.UserValueWidget.include({ +import { + BackgroundToggler, + Box, + CarouselHandler, + GridColumns, + LayoutColumn, + Many2oneUserValue, + registerBackgroundOptions, + registerOption, + ReplaceMedia, + SelectTemplate, + SelectUserValue, + serviceCached, + SnippetMove, + SnippetOption, + SnippetOptionComponent, + SnippetSave, + UserValue, + UserValueComponent, + vAlignment, + WeButton, + WeInput, + WeSelect, +} from '@web_editor/js/editor/snippets.options'; +import { registerWebsiteOption } from "./snippets.registry"; + +patch(SnippetOption.prototype, { loadMethodsData() { - this._super(...arguments); + super.loadMethodsData(...arguments); // Method names are sorted alphabetically by default. Exception here: // we make sure, customizeWebsiteVariable is considered after @@ -61,12 +86,17 @@ options.UserValueWidget.include({ }, }); -Many2oneUserValueWidget.include({ - init() { - this._super(...arguments); - this.fields = this.bindService("field"); +patch(Many2oneUserValue.prototype, { + /** + * @override + */ + constructorPatch() { + // We can't do that with `constructor()` because super calls a static + // property with `this.constructor.prop`. Overriding the constructor in + // a patch makes it impossible to call such static properties. + super.constructorPatch(...arguments); + this.fields = this.env.services.field; }, - /** * @override */ @@ -86,44 +116,38 @@ Many2oneUserValueWidget.include({ }, }); -const UrlPickerUserValueWidget = InputUserValueWidget.extend({ - events: Object.assign({}, InputUserValueWidget.prototype.events || {}, { - 'click .o_we_redirect_to': '_onRedirectTo', - }), - - /** - * @override - */ - start: async function () { - await this._super(...arguments); - const linkButton = document.createElement('we-button'); - const icon = document.createElement('i'); - icon.classList.add('fa', 'fa-fw', 'fa-external-link'); - linkButton.classList.add('o_we_redirect_to', 'o_we_link', 'ms-1'); - linkButton.title = _t("Preview this URL in a new tab"); - linkButton.appendChild(icon); - this.containerEl.after(linkButton); - this.el.classList.add('o_we_large'); - this.inputEl.classList.add('text-start'); - const options = { - classes: { - "ui-autocomplete": 'o_website_ui_autocomplete' - }, - body: this.getParent().$target[0].ownerDocument.body, - urlChosen: this._onWebsiteURLChosen.bind(this), - }; - this.unmountAutocompleteWithPages = wUtils.autocompleteWithPages(this.inputEl, options); - }, +class WeUrlPicker extends WeInput { + static template = "website.WeUrlPicker"; + static defaultProps = { + ...WeInput.defaultProps, + unit: "", + saveUnit: "", + }; + setup() { + super.setup(); + this.website = useService('website'); + useEffect((inputEl) => { + const options = { + classes: { + "ui-autocomplete": 'o_website_ui_autocomplete' + }, + urlChosen: this._onWebsiteURLChosen.bind(this), + }; + const unmountAutocompleteWithPages = wUtils.autocompleteWithPages(inputEl, options); + return () => unmountAutocompleteWithPages(); + }, () => [this.inputRef.el]); + } + // TODO maybe these open & close can be removed open() { - this._super(...arguments); + super.open(...arguments); document.querySelector(".o_website_ui_autocomplete")?.classList?.remove("d-none"); - }, + } close() { - this._super(...arguments); + super.close(...arguments); document.querySelector(".o_website_ui_autocomplete")?.classList?.add("d-none"); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -135,26 +159,22 @@ const UrlPickerUserValueWidget = InputUserValueWidget.extend({ * @private * @param {OdooEvent} ev */ - _onWebsiteURLChosen: function (ev) { - this._value = this.inputEl.value; + _onWebsiteURLChosen(ev) { + this.state.value = this.inputRef.el.value; this._onUserValueChange(ev); - }, + } /** * Redirects to the URL the widget currently holds. * * @private */ - _onRedirectTo: function () { - if (this._value) { - window.open(this._value, '_blank'); + _onRedirectTo() { + if (this.state.value) { + window.open(this.state.value, '_blank'); } - }, - destroy() { - this.unmountAutocompleteWithPages?.(); - this.unmountAutocompleteWithPages = null; - this._super(...arguments); } -}); +} +registry.category("snippet_widgets").add("WeUrlPicker", WeUrlPicker); class GoogleFontAutoComplete extends AutoComplete { setup() { @@ -181,25 +201,9 @@ class GoogleFontAutoComplete extends AutoComplete { } } -const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ - events: Object.assign({}, SelectUserValueWidget.prototype.events || {}, { - 'click .o_we_add_font_btn': '_onAddFontClick', - 'click .o_we_delete_font_btn': '_onDeleteFontClick', - }), - fontVariables: [], // Filled by editor menu when all options are loaded - - /** - * @override - */ - init() { - this.dialog = this.bindService("dialog"); - this.orm = this.bindService("orm"); - return this._super(...arguments); - }, - /** - * @override - */ - start: async function () { +class FontFamilyUserValue extends SelectUserValue { + constructor() { + super(...arguments); const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); const nbFonts = parseInt(weUtils.getCSSVariableValue('number-of-fonts', style)); // User fonts served by google server. @@ -222,8 +226,6 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ }); this.allFonts = []; - await this._super(...arguments); - const fontsToLoad = []; for (const font of this.googleFonts) { const fontURL = `https://fonts.googleapis.com/css?family=${encodeURIComponent(font).replace(/%20/g, '+')}`; @@ -234,16 +236,14 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ const fontURL = `/web/content/${encodeURIComponent(attachmentId)}`; fontsToLoad.push(fontURL); } - // TODO ideally, remove the elements created once this widget - // instance is destroyed (although it should not hurt to keep them for - // the whole backend lifecycle). const proms = fontsToLoad.map(async fontURL => loadCSS(fontURL)); - const fontsLoadingProm = Promise.all(proms); + this.fontsLoadingProm = Promise.all(proms); - const fontEls = []; - const methodName = this.el.dataset.methodName || 'customizeWebsiteVariable'; - const variable = this.el.dataset.variable; + this._fonts = []; const themeFontsNb = nbFonts - (this.googleLocalFonts.length + this.googleFonts.length + this.uploadedLocalFonts.length); + const localFontsOffset = nbFonts - this.googleLocalFonts.length - this.uploadedLocalFonts.length; + const uploadedFontsOffset = nbFonts - this.uploadedLocalFonts.length; + for (let fontNb = 0; fontNb < nbFonts; fontNb++) { const realFontNb = fontNb + 1; const fontKey = weUtils.getCSSVariableValue(`font-number-${realFontNb}`, style); @@ -255,79 +255,60 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ fontName = _t("System Fonts"); fontFamily = 'var(--o-system-fonts)'; } - const fontEl = document.createElement('we-button'); - fontEl.setAttribute('string', fontName); - fontEl.dataset.variable = variable; - fontEl.dataset[methodName] = fontKey; - fontEl.dataset.fontFamily = fontFamily; - const iconWrapperEl = document.createElement("div"); - iconWrapperEl.classList.add("text-end"); - fontEl.appendChild(iconWrapperEl); - if ((realFontNb <= themeFontsNb) && !isSystemFonts) { - // Add the "cloud" icon next to the theme's default fonts - // because they are served by Google. - iconWrapperEl.appendChild(Object.assign(document.createElement('i'), { - role: 'button', - className: 'text-info me-2 fa fa-cloud', - title: _t("This font is hosted and served to your visitors by Google servers"), - })); - } - fontEls.push(fontEl); - this.menuEl.appendChild(fontEl); - } - if (this.uploadedLocalFonts.length) { - const uploadedLocalFontsEls = fontEls.splice(-this.uploadedLocalFonts.length); - uploadedLocalFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - local: "uploaded", - })); - }); - } - - if (this.googleLocalFonts.length) { - const googleLocalFontsEls = fontEls.splice(-this.googleLocalFonts.length); - googleLocalFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - local: "google", - })); - }); - } - - if (this.googleFonts.length) { - const googleFontsEls = fontEls.splice(-this.googleFonts.length); - googleFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - })); + let type = "cloud"; + let indexForType = fontNb - themeFontsNb; + if (fontNb >= localFontsOffset) { + if (fontNb <= uploadedFontsOffset) { + type = "google"; + indexForType = fontNb - localFontsOffset; + } else { + type = "uploaded"; + indexForType = fontNb - uploadedFontsOffset; + } + } + this._fonts.push({ + type, + indexForType, + fontFamily, + string: fontName, }); } + } - $(this.menuEl).append($(renderToElement('website.add_font_btn', { - variable: variable, - }))); + async start() { + return this.fontsLoadingProm; + } - return fontsLoadingProm; - }, + get fonts() { + return this._fonts; + } +} - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- +class WeFontFamilyPicker extends WeSelect { + static isContainer = true; + static StateModel = FontFamilyUserValue; + static template = "website.WeFontFamilyPicker"; + static components = { WeSelect, WeButton }; + static defaultProps = { + ...WeSelect.defaultProps, + selectMethod: "customizeWebsiteVariable", + }; + fontVariables = []; // Filled by editor menu when all options are loaded - /** - * @override - */ - async setValue() { - await this._super(...arguments); + setup() { + super.setup(); + this.dialog = useService("dialog"); + this.orm = useService("orm"); + } - this.menuTogglerEl.style.fontFamily = ''; - const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); - if (activeWidget) { - this.menuTogglerEl.style.fontFamily = activeWidget.el.dataset.fontFamily; - } - }, + forwardProps(fontValue) { + const result = Object.assign({}, this.props, { + [this.props.selectMethod]: fontValue.fontFamily, + }); + delete result.selectMethod; + return result; + } //-------------------------------------------------------------------------- // Handlers @@ -336,7 +317,7 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ /** * @private */ - async _onAddFontClick(ev) { + async _onAddFontClick() { const addFontDialog = class extends Component { static template = "website.dialog.addFont"; static components = { GoogleFontAutoComplete, Dialog }; @@ -488,7 +469,6 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ this.state.uploadedFontFaces = previewFontFaces; } }; - const variable = $(ev.currentTarget).data('variable'); this.dialog.add(addFontDialog, { title: _t("Add a Google font or upload a custom font"), onClickSave: async (state) => { @@ -496,7 +476,7 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ const uploadedFontFaces = state.uploadedFontFaces; let font = undefined; if (uploadedFontName && uploadedFontFaces) { - const fontExistsLocally = this.uploadedLocalFonts.some(localFont => localFont.split(':')[0] === `'${uploadedFontName}'`); + const fontExistsLocally = this.state.uploadedLocalFonts.some(localFont => localFont.split(':')[0] === `'${uploadedFontName}'`); if (fontExistsLocally) { this.dialog.add(ConfirmationDialog, { title: _t("Font exists"), @@ -505,8 +485,8 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ return; } const homonymGoogleFontExists = - this.googleFonts.some(font => font === uploadedFontName) || - this.googleLocalFonts.some(font => font.split(':')[0] === `'${uploadedFontName}'`); + this.state.googleFonts.some(font => font === uploadedFontName) || + this.state.googleLocalFonts.some(font => font.split(':')[0] === `'${uploadedFontName}'`); if (homonymGoogleFontExists) { this.dialog.add(ConfirmationDialog, { title: _t("Font name already used"), @@ -523,7 +503,7 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ mimetype: "text/css", "public": true, }]]); - this.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`); + this.state.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`); font = uploadedFontName; } else { let isValidFamily = false; @@ -552,8 +532,8 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ // If the font already exists, it will only be added if // the user chooses to add it locally when it is already // imported from the Google Fonts server. - const fontExistsLocally = this.googleLocalFonts.some(localFont => localFont.split(':')[0] === fontName); - const fontExistsOnServer = this.allFonts.includes(fontName); + const fontExistsLocally = this.state.googleLocalFonts.some(localFont => localFont.split(':')[0] === fontName); + const fontExistsOnServer = this.state.allFonts.includes(fontName); const preventFontAddition = fontExistsLocally || (fontExistsOnServer && googleFontServe); if (preventFontAddition) { this.dialog.add(ConfirmationDialog, { @@ -563,16 +543,16 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ return; } if (googleFontServe) { - this.googleFonts.push(font); + this.state.googleFonts.push(font); } else { - this.googleLocalFonts.push(`'${font}': ''`); + this.state.googleLocalFonts.push(`'${font}': ''`); } } - this.trigger_up('fonts_custo_request', { - values: {[variable]: `'${font}'`}, - googleFonts: this.googleFonts, - googleLocalFonts: this.googleLocalFonts, - uploadedLocalFonts: this.uploadedLocalFonts, + this.state.option._onFontsCustoRequest({ + values: {[this.props.variable]: `'${font}'`}, + googleFonts: this.state.googleFonts, + googleLocalFonts: this.state.googleLocalFonts, + uploadedLocalFonts: this.state.uploadedLocalFonts, }); let styleEl = document.head.querySelector(`[id='WebsiteThemeFontPreview-${font}']`); if (styleEl) { @@ -588,17 +568,16 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ } }, }); - }, + } /** * @private - * @param {Event} ev + * @param {Event} ev TODO update */ - _onDeleteFontClick: async function (ev) { - ev.preventDefault(); + async _onDeleteFontClick(font) { const values = {}; const save = await new Promise(resolve => { - this.dialog.add(ConfirmationDialog, { + this.env.services.dialog.add(ConfirmationDialog, { body: _t("Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?"), confirm: () => resolve(true), cancel: () => resolve(false), @@ -609,29 +588,29 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ } // Remove Google font - const fontIndex = parseInt(ev.target.dataset.fontIndex); - const localFont = ev.target.dataset.localFont; + const fontIndex = font.indexForType; + const localFont = font.type; let fontName; if (localFont === 'uploaded') { - const font = this.uploadedLocalFonts[fontIndex].split(':'); + const font = this.state.uploadedLocalFonts[fontIndex].split(':'); // Remove double quotes fontName = font[0].substring(1, font[0].length - 1); values['delete-font-attachment-id'] = font[1]; - this.uploadedLocalFonts.splice(fontIndex, 1); + this.state.uploadedLocalFonts.splice(fontIndex, 1); } else if (localFont === 'google') { - const googleFont = this.googleLocalFonts[fontIndex].split(':'); + const googleFont = this.state.googleLocalFonts[fontIndex].split(':'); // Remove double quotes fontName = googleFont[0].substring(1, googleFont[0].length - 1); values['delete-font-attachment-id'] = googleFont[1]; - this.googleLocalFonts.splice(fontIndex, 1); + this.state.googleLocalFonts.splice(fontIndex, 1); } else { - fontName = this.googleFonts[fontIndex]; - this.googleFonts.splice(fontIndex, 1); + fontName = this.state.googleFonts[fontIndex]; + this.state.googleFonts.splice(fontIndex, 1); } // Adapt font variable indexes to the removal - const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); - FontFamilyPickerUserValueWidget.prototype.fontVariables.forEach((variable) => { + const style = window.getComputedStyle(this.state.$target[0].ownerDocument.documentElement); + this.fontVariables.forEach((variable) => { const value = weUtils.getCSSVariableValue(variable, style); if (value.substring(1, value.length - 1) === fontName) { // If an element is using the google font being removed, reset @@ -639,44 +618,29 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ values[variable] = 'null'; } }); - - this.trigger_up('fonts_custo_request', { + this.state.option._onFontsCustoRequest({ values: values, - googleFonts: this.googleFonts, - googleLocalFonts: this.googleLocalFonts, - uploadedLocalFonts: this.uploadedLocalFonts, + googleFonts: this.state.googleFonts, + googleLocalFonts: this.state.googleLocalFonts, + uploadedLocalFonts: this.state.uploadedLocalFonts, }); - }, -}); - -const GPSPicker = InputUserValueWidget.extend({ - // Explicitly not consider all InputUserValueWidget events. E.g. we actually - // don't want input focusout messing with the google map API. Because of - // this, clicking on google map autocomplete suggestion on Firefox was not - // working properly. - events: {}, + } +} +registry.category("snippet_widgets").add("WeFontFamilyPicker", WeFontFamilyPicker); - /** - * @constructor - */ - init() { - this._super(...arguments); - this._gmapCacheGPSToPlace = {}; +class GpsUserValue extends UserValue { + _gmapCacheGPSToPlace = {}; - // The google API will be loaded inside the website iframe. Let's try - // not having to load it in the backend too and just using the iframe - // google object instead. + constructor() { + super(...arguments); + this._state._gmapLoaded = false; + this._state.gmapPlace = {}; this.contentWindow = this.$target[0].ownerDocument.defaultView; - - this.notification = this.bindService("notification"); - }, - /** - * @override - */ - async willStart() { - await this._super(...arguments); - this._gmapLoaded = await new Promise(resolve => { - this.trigger_up('gmap_api_request', { + } + async start() { + super.start(); + this._state._gmapLoaded = await new Promise(resolve => { + this.env.gmapApiRequest({ editableMode: true, configureIfNecessary: true, onSuccess: key => { @@ -687,44 +651,16 @@ const GPSPicker = InputUserValueWidget.extend({ // TODO see _notifyGMapError, this tries to trigger an error // early but this is not consistent with new gmap keys. - this._nearbySearch('(50.854975,4.3753899)', !!key) - .then(place => resolve(!!place)); + const startLocation = this.$target[0].dataset.mapGps || "(50.854975,4.3753899)"; + this._nearbySearch(startLocation, !!key) + .then(place => { + this._state.gmapPlace = place; + resolve(!!place); + }); }, }); }); - if (!this._gmapLoaded && !this._gmapErrorNotified) { - this.trigger_up('user_value_widget_critical'); - return; - } - }, - /** - * @override - */ - async start() { - await this._super(...arguments); - this.el.classList.add('o_we_large'); - if (!this._gmapLoaded) { - return; - } - - this._gmapAutocomplete = new this.contentWindow.google.maps.places.Autocomplete(this.inputEl, {types: ['geocode']}); - this.contentWindow.google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this)); - }, - /** - * @override - */ - destroy() { - this._super(...arguments); - - // Without this, the google library injects elements inside the backend - // DOM but do not remove them once the editor is left. Notice that - // this is also done when the widget is destroyed for another reason - // than leaving the editor, but if the google API needs that container - // again afterwards, it will simply recreate it. - for (const el of document.body.querySelectorAll('.pac-container')) { - el.remove(); - } - }, + } //-------------------------------------------------------------------------- // Public @@ -733,24 +669,26 @@ const GPSPicker = InputUserValueWidget.extend({ /** * @override */ - getMethodsParams: function (methodName) { - return Object.assign({gmapPlace: this._gmapPlace || {}}, this._super(...arguments)); - }, + getMethodsParams(methodName) { + return Object.assign({gmapPlace: this._state.gmapPlace || {}}, super.getMethodsParams(...arguments)); + } /** * @override */ async setValue() { - await this._super(...arguments); - if (!this._gmapLoaded) { + await super.setValue(...arguments); + if (!this._state._gmapLoaded) { return; } - this._gmapPlace = await this._nearbySearch(this._value); - - if (this._gmapPlace) { - this.inputEl.value = this._gmapPlace.formatted_address; - } - }, + this._state.gmapPlace = await this._nearbySearch(this.value); + } + get formattedAddress() { + return this._state.gmapPlace?.formatted_address; + } + get isGmapLoaded() { + return this._state._gmapLoaded; + } //-------------------------------------------------------------------------- // Private @@ -808,7 +746,7 @@ const GPSPicker = InputUserValueWidget.extend({ } }); }); - }, + } /** * Indicates to the user there is an error with the google map API and * re-opens the configuration dialog. For good measures, this also notifies @@ -827,11 +765,11 @@ const GPSPicker = InputUserValueWidget.extend({ } this._gmapErrorNotified = true; - this.notification.add( + this.env.services.notification.add( _t("A Google Map error occurred. Make sure to read the key configuration popup carefully."), { type: 'danger', sticky: true } ); - this.trigger_up('gmap_api_request', { + this.env.services.website.websiteRootInstance.trigger_up('gmap_api_request', { editableMode: true, reconfigure: true, onSuccess: () => { @@ -839,8 +777,39 @@ const GPSPicker = InputUserValueWidget.extend({ }, }); - setTimeout(() => this.trigger_up('user_value_widget_critical')); - }, + // TODO user_value_widget_critical + setTimeout(() => this.env.services.website.websiteRootInstance.trigger_up('user_value_widget_critical')); + } +} + +class WeGpsPicker extends UserValueComponent { + static template = "website.WeGpsPicker"; + static StateModel = GpsUserValue; + setup() { + super.setup(); + this.inputRef = useRef("input"); + + // The google API will be loaded inside the website iframe. Let's try + // not having to load it in the backend too and just using the iframe + // google object instead. + useEffect((gmapLoaded, inputEl) => { + if (gmapLoaded && inputEl) { + const contentWindow = this.state.$target[0].ownerDocument.defaultView; + this._gmapAutocomplete = new contentWindow.google.maps.places.Autocomplete(this.inputRef.el, {types: ['geocode']}); + contentWindow.google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this)); + } + }, () => [this.state.isGmapLoaded, this.inputRef.el]); + onWillUnmount(() => { + // Without this, the google library injects elements inside the backend + // DOM but do not remove them once the editor is left. Notice that + // this is also done when the widget is destroyed for another reason + // than leaving the editor, but if the google API needs that container + // again afterwards, it will simply recreate it. + for (const el of document.body.querySelectorAll('.pac-container')) { + el.remove(); + } + }); + } //-------------------------------------------------------------------------- // Handlers @@ -853,34 +822,39 @@ const GPSPicker = InputUserValueWidget.extend({ _onPlaceChanged(ev) { const gmapPlace = this._gmapAutocomplete.getPlace(); if (gmapPlace && gmapPlace.geometry) { - this._gmapPlace = gmapPlace; - const location = this._gmapPlace.geometry.location; - const oldValue = this._value; - this._value = `(${location.lat()},${location.lng()})`; - this._gmapCacheGPSToPlace[this._value] = gmapPlace; - if (oldValue !== this._value) { + this.state.gmapPlace = gmapPlace; + const location = this.state.gmapPlace.geometry.location; + const oldValue = this.state.value; + this.state.value = `(${location.lat()},${location.lng()})`; + this.state._gmapCacheGPSToPlace[this.state.value] = gmapPlace; + if (oldValue !== this.state.value) { this._onUserValueChange(ev); } } - }, -}); + } +} +registry.category("snippet_widgets").add("WeGpsPicker", WeGpsPicker); +/* options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget; options.userValueWidgetsRegistry['we-fontfamilypicker'] = FontFamilyPickerUserValueWidget; options.userValueWidgetsRegistry['we-gpspicker'] = GPSPicker; +*/ //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: -options.Class.include({ - custom_events: Object.assign({}, options.Class.prototype.custom_events || {}, { - 'fonts_custo_request': '_onFontsCustoRequest', - }), - specialCheckAndReloadMethodsNames: ['customizeWebsiteViews', 'customizeWebsiteVariable', 'customizeWebsiteColor'], +patch(SnippetOption.prototype, { + specialCheckAndReloadMethodsNames: [ + 'customizeWebsiteViews', + 'customizeWebsiteVariable', + 'customizeWebsiteColor', + 'customizeWebsiteLayer2Color', + ], /** * @override */ - init() { - this._super(...arguments); + constructorPatch() { + super.constructorPatch(...arguments); // Since the website is displayed in an iframe, its jQuery // instance is not the same as the editor. This property allows // for easy access to bootstrap plugins (Carousel, Modal, ...). @@ -889,8 +863,6 @@ options.Class.include({ // triggers a custom event, only that same jQuery instance will // trigger handlers set with `.on`. this.$bsTarget = this.ownerDocument.defaultView.$(this.$target[0]); - - this.orm = this.bindService("orm"); }, //-------------------------------------------------------------------------- @@ -936,7 +908,7 @@ options.Class.include({ * @override */ async _checkIfWidgetsUpdateNeedReload(widgets) { - const needReload = await this._super(...arguments); + const needReload = await super._checkIfWidgetsUpdateNeedReload(...arguments); if (needReload) { return needReload; } @@ -954,7 +926,7 @@ options.Class.include({ /** * @override */ - _computeWidgetState: async function (methodName, params) { + async _computeWidgetState(methodName, params) { switch (methodName) { case 'customizeWebsiteViews': { return this._getEnabledCustomizeValues(params.possibleValues, true); @@ -982,7 +954,7 @@ options.Class.include({ return this._getEnabledCustomizeValues(params.possibleValues, false); } } - return this._super(...arguments); + return super._computeWidgetState(...arguments); }, /** * @private @@ -1038,7 +1010,7 @@ options.Class.include({ // Some public widgets may depend on the variables that were // customized, so we have to restart them *all*. await new Promise((resolve, reject) => { - this.trigger_up('widgets_start_request', { + this.env.services.website.websiteRootInstance.trigger_up('widgets_start_request', { editableMode: true, onSuccess: () => resolve(), onFailure: () => reject(), @@ -1152,7 +1124,7 @@ options.Class.include({ Object.keys(values).forEach((key) => { values[key] = values[key] || defaultValue; }); - return this.orm.call("web_editor.assets", "make_scss_customization", [url, values]); + return this.env.services.orm.call("web_editor.assets", "make_scss_customization", [url, values]); }, /** * Refreshes all public widgets related to the given element. @@ -1163,7 +1135,7 @@ options.Class.include({ */ _refreshPublicWidgets: async function ($el) { return new Promise((resolve, reject) => { - this.trigger_up('widgets_start_request', { + this.env.services.website.websiteRootInstance.trigger_up('widgets_start_request', { editableMode: true, $target: $el || this.$target, onSuccess: resolve, @@ -1176,7 +1148,7 @@ options.Class.include({ */ _reloadBundles: async function() { return new Promise((resolve, reject) => { - this.trigger_up('reload_bundles', { + this.env.reloadBundles({ onSuccess: () => resolve(), onFailure: () => reject(), }); @@ -1185,15 +1157,15 @@ options.Class.include({ /** * @override */ - _select: async function (previewMode, widget) { - await this._super(...arguments); + async _select(previewMode, widget) { + await super._select(...arguments); // Some blocks flicker when we start their public widgets, so we skip // the refresh for them to avoid the flickering. const targetNoRefreshSelector = ".s_instagram_page"; // TODO: we should review the way public widgets are restarted when // converting to OWL and a new API. - if (this.options.isWebsite && !widget.$el.closest('[data-no-widget-refresh="true"]').length + if (this.options.isWebsite && widget._methodsParams.noWidgetRefresh !== "true" && !this.$target[0].matches(targetNoRefreshSelector)) { // TODO the flag should be retrieved through widget params somehow await this._refreshPublicWidgets(); @@ -1206,13 +1178,11 @@ options.Class.include({ /** * @private - * @param {OdooEvent} ev + * TODO: @owl-options update doc + * @param {Object} */ - _onFontsCustoRequest(ev) { - const values = ev.data.values ? Object.assign({}, ev.data.values) : {}; - const googleFonts = ev.data.googleFonts; - const googleLocalFonts = ev.data.googleLocalFonts; - const uploadedLocalFonts = ev.data.uploadedLocalFonts; + _onFontsCustoRequest({values, googleFonts, googleLocalFonts, uploadedLocalFonts}) { + values = values ? Object.assign({}, values) : {}; if (googleFonts.length) { values['google-fonts'] = "('" + googleFonts.join("', '") + "')"; } else { @@ -1228,10 +1198,10 @@ options.Class.include({ } else { values['uploaded-local-fonts'] = 'null'; } - this.trigger_up('snippet_edition_request', {exec: async () => { + this.options.snippetEditionRequest({exec: async () => { return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values); }}); - this.trigger_up('request_save', { + this.env.requestSave({ reloadEditor: true, }); }, @@ -1251,7 +1221,7 @@ function _getLastPreFilterLayerElement($el) { return null; } -options.registry.BackgroundToggler.include({ +patch(BackgroundToggler.prototype, { /** * Toggles background video on or off. * @@ -1262,11 +1232,11 @@ options.registry.BackgroundToggler.include({ this.$target.find('> .o_we_bg_filter').remove(); // TODO: use setWidgetValue instead of calling background directly when possible const [bgVideoWidget] = this._requestUserValueWidgets('bg_video_opt'); - const bgVideoOpt = bgVideoWidget.getParent(); + const bgVideoOpt = bgVideoWidget.option; return bgVideoOpt._setBgVideo(false, ''); } else { // TODO: use trigger instead of el.click when possible - this._requestUserValueWidgets('bg_video_opt')[0].el.click(); + this._requestUserValueWidgets('bg_video_opt')[0].enable(); } }, @@ -1281,7 +1251,7 @@ options.registry.BackgroundToggler.include({ if (methodName === 'toggleBgVideo') { return this.$target[0].classList.contains('o_background_video'); } - return this._super(...arguments); + return super._computeWidgetState(...arguments); }, /** * TODO an overall better management of background layers is needed @@ -1293,7 +1263,7 @@ options.registry.BackgroundToggler.include({ if (el) { return el; } - return this._super(...arguments); + return super._getLastPreFilterLayerElement(...arguments); }, }); @@ -1321,7 +1291,7 @@ options.registry.BackgroundShape.include({ }, }); -options.registry.ReplaceMedia.include({ +patch(ReplaceMedia.prototype, { /** * Adds an anchor to the url. * Here "anchor" means a specific section of a page. @@ -1351,7 +1321,7 @@ options.registry.ReplaceMedia.include({ } return ''; } - return this._super(...arguments); + return super._computeWidgetState(...arguments); }, /** * @override @@ -1363,55 +1333,11 @@ options.registry.ReplaceMedia.include({ const href = linkEl ? linkEl.getAttribute('href') : false; return href && href.startsWith('/'); } - return this._super(...arguments); - }, - /** - * Fills the dropdown with the available anchors for the page referenced in - * the href. - * - * @override - */ - async _renderCustomXML(uiFragment) { - if (!this.options.isWebsite) { - return this._super(...arguments); - } - await this._super(...arguments); - - - - const oldURLWidgetEl = uiFragment.querySelector('[data-name="media_url_opt"]'); - - const URLWidgetEl = document.createElement('we-urlpicker'); - // Copy attributes - for (const {name, value} of oldURLWidgetEl.attributes) { - URLWidgetEl.setAttribute(name, value); - } - URLWidgetEl.title = _t("Hint: Type '/' to search an existing page and '#' to link to an anchor."); - oldURLWidgetEl.replaceWith(URLWidgetEl); - - const hrefValue = this.$target[0].parentElement.getAttribute('href'); - if (!hrefValue || !hrefValue.startsWith('/')) { - return; - } - const urlWithoutAnchor = hrefValue.split('#')[0]; - const selectEl = document.createElement('we-select'); - selectEl.dataset.name = 'media_link_anchor_opt'; - selectEl.dataset.dependencies = 'media_url_opt'; - selectEl.dataset.noPreview = 'true'; - selectEl.classList.add('o_we_sublevel_1'); - selectEl.setAttribute('string', _t("Page Anchor")); - const anchors = await wUtils.loadAnchors(urlWithoutAnchor); - for (const anchor of anchors) { - const weButtonEl = document.createElement('we-button'); - weButtonEl.dataset.setAnchor = anchor; - weButtonEl.textContent = anchor; - selectEl.append(weButtonEl); - } - URLWidgetEl.after(selectEl); + return super._computeWidgetVisibility(...arguments); }, }); -options.registry.BackgroundVideo = options.Class.extend({ +class BackgroundVideo extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -1422,12 +1348,12 @@ options.registry.BackgroundVideo = options.Class.extend({ * * @see this.selectClass for parameters */ - background: function (previewMode, widgetValue, params) { + background(previewMode, widgetValue, params) { if (previewMode === 'reset' && this.videoSrc) { return this._setBgVideo(false, this.videoSrc); } return this._setBgVideo(previewMode, widgetValue); - }, + } //-------------------------------------------------------------------------- // Private @@ -1436,7 +1362,7 @@ options.registry.BackgroundVideo = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'background') { if (this.$target[0].classList.contains('o_background_video')) { return this.$('> .o_bg_video_container iframe').attr('src'); @@ -1444,7 +1370,7 @@ options.registry.BackgroundVideo = options.Class.extend({ return ''; } return this._super(...arguments); - }, + } /** * Updates the background video used by the snippet. * @@ -1452,7 +1378,7 @@ options.registry.BackgroundVideo = options.Class.extend({ * @see this.selectClass for parameters * @returns {Promise} */ - _setBgVideo: async function (previewMode, value) { + async _setBgVideo(previewMode, value) { this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true); if (previewMode !== false) { @@ -1468,19 +1394,17 @@ options.registry.BackgroundVideo = options.Class.extend({ delete target.dataset.bgVideoSrc; } await this._refreshPublicWidgets(); - }, -}); + } +} -options.registry.WebsiteLevelColor = options.Class.extend({ - specialCheckAndReloadMethodsNames: options.Class.prototype.specialCheckAndReloadMethodsNames - .concat(['customizeWebsiteLayer2Color']), +export class WebsiteLevelColor extends SnippetOption { /** * @constructor */ - init() { - this._super(...arguments); + constructor() { + super(...arguments); this._rpc = options.serviceCached(rpc); - }, + } /** * @see this.selectClass for parameters */ @@ -1502,7 +1426,7 @@ options.registry.WebsiteLevelColor = options.Class.extend({ await this.customizeWebsiteVariable(previewMode, gradient, params); params.noBundleReload = false; return this.customizeWebsiteColor(previewMode, color, params); - }, + } //-------------------------------------------------------------------------- // Private @@ -1521,13 +1445,12 @@ options.registry.WebsiteLevelColor = options.Class.extend({ params.color = params.layerColor; return this._computeWidgetState('customizeWebsiteColor', params); } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ async _computeWidgetVisibility(widgetName, params) { - const _super = this._super.bind(this); if ( [ "footer_language_selector_label_opt", @@ -1539,22 +1462,51 @@ options.registry.WebsiteLevelColor = options.Class.extend({ return false; } } - return _super(...arguments); + return super._computeWidgetVisibility(...arguments); + } +} + +registerWebsiteOption("Header", { + Class: WebsiteLevelColor, + template: "website.header_option", + selector: "#wrapwrap > header", + noCheck: true, + data: { + groups: ["website.group_website_designer"], }, }); -options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ - GRAY_PARAMS: {EXTRA_SATURATION: "gray-extra-saturation", HUE: "gray-hue"}, +registerWebsiteOption("Footer", { + Class: WebsiteLevelColor, + template: "website.footer_option", + selector: "#wrapwrap > footer", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, +}); + +registerWebsiteOption("Footer Copyright", { + Class: WebsiteLevelColor, + template: "website.footer_copyright_option", + selector: ".o_footer_copyright", + noCheck: true, + data: { + groups: ["website.group_website_designer"], + }, +}); + +export class OptionsTab extends WebsiteLevelColor { + static GRAY_PARAMS = {EXTRA_SATURATION: "gray-extra-saturation", HUE: "gray-hue"}; /** * @override */ - init() { - this._super(...arguments); + constructor() { + super(...arguments); this.grayParams = {}; this.grays = {}; - this.orm = this.bindService("orm"); - }, + } //-------------------------------------------------------------------------- // Public @@ -1568,11 +1520,6 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ // option like changing color palette) -> update the preview element. const ownerDocument = this.$target[0].ownerDocument; const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); - const grayPreviewEls = this.$el.find(".o_we_gray_preview span"); - for (const e of grayPreviewEls) { - const bgValue = weUtils.getCSSVariableValue(e.getAttribute('variable'), style); - e.style.setProperty("background-color", bgValue, "important"); - } // If the gray palette has been generated by Odoo standard option, // the hue of all gray is the same and the saturation has been @@ -1613,19 +1560,19 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ // allows to represent more colors that the RGB hexadecimal // notation (also: hue 360 = hue 0 and should not be averaged to 180). // This also better support random gray palettes. - this.grayParams[this.GRAY_PARAMS.HUE] = (!hues.length) ? 0 : Math.round((Math.atan2( + this.grayParams[OptionsTab.GRAY_PARAMS.HUE] = (!hues.length) ? 0 : Math.round((Math.atan2( hues.map(hue => Math.sin(hue * Math.PI / 180)).reduce((memo, value) => memo + value, 0) / hues.length, hues.map(hue => Math.cos(hue * Math.PI / 180)).reduce((memo, value) => memo + value, 0) / hues.length ) * 180 / Math.PI) + 360) % 360; // Average of found saturation diffs, or all grays have no // saturation, or all grays are fully saturated. - this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION] = saturationDiffs.length + this.grayParams[OptionsTab.GRAY_PARAMS.EXTRA_SATURATION] = saturationDiffs.length ? saturationDiffs.reduce((memo, value) => memo + value, 0) / saturationDiffs.length : (oneHasNoSaturation ? -100 : 100); - await this._super(...arguments); - }, + await super.updateUI(...arguments); + } //-------------------------------------------------------------------------- // Options @@ -1649,11 +1596,6 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ this.grays[key] = this._buildGray(key); } - // Preview UI update - this.$el.find(".o_we_gray_preview").each((_, e) => { - e.style.setProperty("background-color", this.grays[e.getAttribute('variable')], "important"); - }); - // Save all computed (JS side) grays in database await this._customizeWebsite(previewMode, undefined, Object.assign({}, params, { customCustomization: () => { // TODO this could be prettier @@ -1662,19 +1604,19 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ })); }, })); - }, + } /** * @see this.selectClass for parameters */ async configureApiKey(previewMode, widgetValue, params) { return new Promise(resolve => { - this.trigger_up('gmap_api_key_request', { + this.env.gmapApiKeyRequest({ editableMode: true, reconfigure: true, onSuccess: () => resolve(), }); }); - }, + } /** * @see this.selectClass for parameters */ @@ -1687,7 +1629,7 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ this.bodyImageType = widgetValue; const widget = this._requestUserValueWidgets(params.imagepicker)[0]; widget.enable(); - }, + } /** * @override */ @@ -1696,14 +1638,14 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ 'body-image-type': this.bodyImageType, 'body-image': widgetValue ? `'${widgetValue}'` : '', }, params.nullValue); - }, + } async openCustomCodeDialog(previewMode, widgetValue, params) { return new Promise(resolve => { - this.trigger_up('open_edit_head_body_dialog', { - onSuccess: resolve, + this.options.wysiwyg._onOpenEditHeadBodyDialog({ + data: {onSuccess: resolve}, }); }); - }, + } /** * @see this.selectClass for parameters */ @@ -1718,11 +1660,11 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ if (!save) { return; } - this.trigger_up('request_save', { + this.env.requestSave({ reload: false, action: 'website.theme_install_kanban_action', }); - }, + } /** * @see this.selectClass for parameters */ @@ -1740,7 +1682,7 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ if (!save) { return; } - this.trigger_up("request_save", { + this.env.requestSave({ reload: false, action: "base.action_view_base_language_install", options: { @@ -1752,7 +1694,7 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ }, } }); - }, + } /** * @see this.selectClass for parameters */ @@ -1761,7 +1703,7 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ [`btn-${params.button}-outline`]: widgetValue === "outline" ? "true" : "false", [`btn-${params.button}-flat`]: widgetValue === "flat" ? "true" : "false", }, params.nullValue); - }, + } //-------------------------------------------------------------------------- // Private @@ -1777,40 +1719,51 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ const gray = weUtils.getCSSVariableValue(`base-${id}`, getComputedStyle(document.documentElement)); const grayRGB = convertCSSColorToRgba(gray); const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); - const adjustedGrayRGB = convertHslToRgb(this.grayParams[this.GRAY_PARAMS.HUE], - Math.min(Math.max(hsl.saturation + this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION], 0), 100), + const adjustedGrayRGB = convertHslToRgb(this.grayParams[OptionsTab.GRAY_PARAMS.HUE], + Math.min(Math.max(hsl.saturation + this.grayParams[OptionsTab.GRAY_PARAMS.EXTRA_SATURATION], 0), 100), hsl.lightness); return convertRgbaToCSSColor(adjustedGrayRGB.red, adjustedGrayRGB.green, adjustedGrayRGB.blue); - }, + } + /** + * @override + */ + async _getRenderContext() { + const context = await super._getRenderContext(...arguments); + this._updateRenderContext(context); + return context; + } + _updateRenderContext(context) { + context = context || this.renderContext; + context.grays = this.grays; + const baseGrays = range(100, 1000, 100).map(id => { + const gray = weUtils.getCSSVariableValue(`base-${id}`); + const grayRGB = convertCSSColorToRgba(gray); + const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); + return {id: id, hsl: hsl}; + }); + const first = baseGrays[0]; + const maxValue = baseGrays.reduce((gray, value) => { + return gray.hsl.saturation > value.hsl.saturation ? gray : value; + }, first); + const minValue = baseGrays.reduce((gray, value) => { + return gray.hsl.saturation < value.hsl.saturation ? gray : value; + }, first); + context.extraSaturationRangeMax = 100 - minValue.hsl.saturation; + context.extraSaturationRangeMin = -maxValue.hsl.saturation; + return context; + } /** * @override */ async _renderCustomXML(uiFragment) { - await this._super(...arguments); - const extraSaturationRangeEl = uiFragment.querySelector(`we-range[data-param=${this.GRAY_PARAMS.EXTRA_SATURATION}]`); - if (extraSaturationRangeEl) { - const baseGrays = range(100, 1000, 100).map(id => { - const gray = weUtils.getCSSVariableValue(`base-${id}`); - const grayRGB = convertCSSColorToRgba(gray); - const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); - return {id: id, hsl: hsl}; - }); - const first = baseGrays[0]; - const maxValue = baseGrays.reduce((gray, value) => { - return gray.hsl.saturation > value.hsl.saturation ? gray : value; - }, first); - const minValue = baseGrays.reduce((gray, value) => { - return gray.hsl.saturation < value.hsl.saturation ? gray : value; - }, first); - extraSaturationRangeEl.dataset.max = 100 - minValue.hsl.saturation; - extraSaturationRangeEl.dataset.min = -maxValue.hsl.saturation; - } - }, + await super._renderCustomXML(...arguments); + this._updateRenderContext(); + } /** * @override */ async _checkIfWidgetsUpdateNeedWarning(widgets) { - const warningMessage = await this._super(...arguments); + const warningMessage = await super._checkIfWidgetsUpdateNeedWarning(...arguments); if (warningMessage) { return warningMessage; } @@ -1824,7 +1777,7 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ } } return ''; - }, + } /** * @override */ @@ -1845,8 +1798,8 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ const isFlat = weUtils.getCSSVariableValue(`btn-${params.button}-flat`); return isFlat === "true" ? "flat" : isOutline === "true" ? "outline" : "fill"; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -1854,7 +1807,7 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ if (widgetName === 'body_bg_image_opt') { return false; } - if (params.param === this.GRAY_PARAMS.HUE) { + if (params.param === OptionsTab.GRAY_PARAMS.HUE) { return this.grayHueIsDefined; } if (params.removeFont) { @@ -1863,36 +1816,23 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ }); return !!font; } - return this._super(...arguments); - }, -}); + return super._computeWidgetVisibility(...arguments); + } +} -options.registry.ThemeColors = options.registry.OptionsTab.extend({ +export class ThemeColors extends OptionsTab { /** * @override */ - async start() { + async willStart() { // Checks for support of the old color system const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); const supportOldColorSystem = weUtils.getCSSVariableValue('support-13-0-color-system', style) === 'true'; const hasCustomizedOldColorSystem = weUtils.getCSSVariableValue('has-customized-13-0-color-system', style) === 'true'; this._showOldColorSystemWarning = supportOldColorSystem && hasCustomizedOldColorSystem; - return this._super(...arguments); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @override - */ - async updateUIVisibility() { - await this._super(...arguments); - const oldColorSystemEl = this.el.querySelector('.o_old_color_system_warning'); - oldColorSystemEl.classList.toggle('d-none', !this._showOldColorSystemWarning); - }, + return super.willStart(...arguments); + } //-------------------------------------------------------------------------- // Private @@ -1901,39 +1841,74 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({ /** * @override */ - async _renderCustomXML(uiFragment) { - const paletteSelectorEl = uiFragment.querySelector('[data-variable="color-palettes-name"]'); + async _getRenderContext() { + const context = await super._getRenderContext(...arguments); + context.showOldColorSystemWarning = this._showOldColorSystemWarning; + + // Prepare palette colors const style = window.getComputedStyle(document.documentElement); const allPaletteNames = weUtils.getCSSVariableValue('palette-names', style).split(', ').map((name) => { return name.replace(/'/g, ""); }); - for (const paletteName of allPaletteNames) { - const btnEl = document.createElement('we-button'); - btnEl.classList.add('o_palette_color_preview_button'); - btnEl.dataset.customizeWebsiteVariable = `'${paletteName}'`; - [1, 3, 2].forEach(c => { - const colorPreviewEl = document.createElement('span'); - colorPreviewEl.classList.add('o_palette_color_preview'); - const color = weUtils.getCSSVariableValue(`o-palette-${paletteName}-o-color-${c}`, style); - colorPreviewEl.style.backgroundColor = color; - btnEl.appendChild(colorPreviewEl); - }); - paletteSelectorEl.appendChild(btnEl); - } + context.palettes = allPaletteNames.map((paletteName) => { + return { + name: paletteName, + colors: [1, 3, 2].map((c) => { + return weUtils.getCSSVariableValue(`o-palette-${paletteName}-o-color-${c}`, style); + }), + }; + }); + return context; + } +} - const presetCollapseEl = uiFragment.querySelector('we-collapse.o_we_theme_presets_collapse'); - let ccPreviewEls = []; - for (let i = 1; i <= 5; i++) { - const collapseEl = document.createElement('we-collapse'); - const ccPreviewEl = $(renderToElement('web_editor.color.combination.preview.legacy'))[0]; - ccPreviewEl.classList.add('text-center', `o_cc${i}`, 'o_colored_level', 'o_we_collapse_toggler'); - collapseEl.appendChild(ccPreviewEl); - collapseEl.appendChild(renderToFragment('website.color_combination_edition', {number: i})); - ccPreviewEls.push(ccPreviewEl); - presetCollapseEl.appendChild(collapseEl); - } - await this._super(...arguments); - }, +registerWebsiteOption("ThemeColors", { + Class: ThemeColors, + template: "website.theme_colors_option", + selector: "theme-colors", + noCheck: true, +}); +registerWebsiteOption("Theme Settings", { + Class: OptionsTab, + template: "website.theme_settings_option", + selector: "website-settings", + noCheck: true, +}); +registerWebsiteOption("Theme Paragraph", { + Class: OptionsTab, + template: "website.theme_paragraph_option", + selector: "theme-paragraph", + noCheck: true, +}); +registerWebsiteOption("Theme Headings", { + Class: OptionsTab, + template: "website.theme_headings_option", + selector: "theme-headings", + noCheck: true, +}); +registerWebsiteOption("Theme Button", { + Class: OptionsTab, + template: "website.theme_button_option", + selector: "theme-button", + noCheck: true, +}); +registerWebsiteOption("Theme Link", { + Class: OptionsTab, + template: "website.theme_link_option", + selector: "theme-link", + noCheck: true, +}); +registerWebsiteOption("Theme Input", { + Class: OptionsTab, + template: "website.theme_input_option", + selector: "theme-input", + noCheck: true, +}); +registerWebsiteOption("Theme Advanced", { + Class: OptionsTab, + template: "website.theme_advanced_option", + selector: "theme-advanced", + noCheck: true, }); options.registry.menu_data = options.Class.extend({ @@ -2012,11 +1987,13 @@ options.registry.menu_data = options.Class.extend({ }, }); -options.registry.Carousel = options.registry.CarouselHandler.extend({ +class Carousel extends CarouselHandler { /** * @override */ - start: function () { + constructor() { + super(...arguments); + this.$bsTarget.carousel('pause'); this.$indicators = this.$target.find('.carousel-indicators'); this.$controls = this.$target.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); @@ -2029,7 +2006,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ let _slideTimestamp; this.$bsTarget.on('slide.bs.carousel.carousel_option', () => { _slideTimestamp = window.performance.now(); - setTimeout(() => this.trigger_up('hide_overlay')); + setTimeout(() => this.env.hideOverlay()); }); this.$bsTarget.on('slid.bs.carousel.carousel_option', () => { // slid.bs.carousel is most of the time fired too soon by bootstrap @@ -2038,52 +2015,48 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ // should be enough... const _slideDuration = (window.performance.now() - _slideTimestamp); setTimeout(() => { - this.trigger_up('activate_snippet', { - $snippet: this.$target.find('.carousel-item.active'), - ifInactiveOptions: true, - }); + this.env.activateSnippet(this.$target.find('.carousel-item.active'), false, true); this.$bsTarget.trigger('active_slide_targeted'); }, 0.2 * _slideDuration); }); - - return this._super.apply(this, arguments); - }, + } /** * @override */ - destroy: function () { - this._super.apply(this, arguments); + destroy() { + super.destroy(...arguments); this.$bsTarget.off('.carousel_option'); - }, + } /** * @override */ - onBuilt: function () { + onBuilt() { this._assignUniqueID(); - }, + } /** * @override */ - onClone: function () { + onClone() { this._assignUniqueID(); - }, + } /** * @override */ - cleanForSave: function () { + // TODO: @owl-options check if this should be cleanUI() rather than cleanForSave() + cleanUI() { const $items = this.$target.find('.carousel-item'); $items.removeClass('next prev left right active').first().addClass('active'); this.$indicators.find('li').removeClass('active').empty().first().addClass('active'); - }, + } /** * @override */ - notify: function (name, data) { - this._super(...arguments); + notify(name, data) { + super.notify(...arguments); if (name === 'add_slide') { this._addSlide(); } - }, + } //-------------------------------------------------------------------------- // Options @@ -2094,7 +2067,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ */ addSlide(previewMode, widgetValue, params) { this._addSlide(); - }, + } //-------------------------------------------------------------------------- // Private @@ -2106,7 +2079,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ * * @private */ - _assignUniqueID: function () { + _assignUniqueID() { const id = 'myCarousel' + Date.now(); this.$target.attr('id', id); this.$target.find('[data-bs-target]').attr('data-bs-target', '#' + id); @@ -2118,7 +2091,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ $el.attr('href', '#' + id); } }); - }, + } /** * Adds a slide. * @@ -2138,13 +2111,13 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ .removeClass('active') .insertAfter($active); this.$bsTarget.carousel('next'); - }, + } /** * @override */ _getItemsGallery() { return Array.from(this.$target[0].querySelectorAll(".carousel-item")); - }, + } /** * @override */ @@ -2157,36 +2130,36 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ carouselInnerEl.append(itemsEl); } this._updateIndicatorAndActivateSnippet(newItemPosition); - }, - + } +} +registerWebsiteOption("Carousel", { + Class: Carousel, + template: "website.Carousel", + selector: "section", + target: "> .carousel", }); -options.registry.CarouselItem = options.Class.extend({ - isTopOption: true, - forceNoDeleteButton: true, +class CarouselItem extends SnippetOption { + static isTopOption = true; + static forceNoDeleteButton = true; /** * @override */ - start: function () { + constructor() { + super(...arguments); + this.$carousel = this.$bsTarget.closest('.carousel'); this.$indicators = this.$carousel.find('.carousel-indicators'); this.$controls = this.$carousel.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); - - var leftPanelEl = this.$overlay.data('$optionsSection')[0]; - var titleTextEl = leftPanelEl.querySelector('we-title > span'); - this.counterEl = document.createElement('span'); - titleTextEl.appendChild(this.counterEl); - - return this._super(...arguments); - }, + } /** * @override */ - destroy: function () { - this._super(...arguments); + destroy() { + super.destroy(...arguments); this.$carousel.off('.carousel_item_option'); - }, + } //-------------------------------------------------------------------------- // Public @@ -2197,13 +2170,14 @@ options.registry.CarouselItem = options.Class.extend({ * * @override */ - updateUI: async function () { - await this._super(...arguments); + async updateUI() { + await super.updateUI(...arguments); const $items = this.$carousel.find('.carousel-item'); const $activeSlide = $items.filter('.active'); - const updatedText = ` (${$activeSlide.index() + 1}/${$items.length})`; - this.counterEl.textContent = updatedText; - }, + // TODO: @owl-options: block the editor UI until the new options are + // created. + this.callbacks.updateExtraTitle(` (${$activeSlide.index() + 1}/${$items.length})`); + } //-------------------------------------------------------------------------- // Options @@ -2213,17 +2187,17 @@ options.registry.CarouselItem = options.Class.extend({ * @see this.selectClass for parameters */ addSlideItem(previewMode, widgetValue, params) { - this.trigger_up('option_update', { + this.callbacks.notifyOptions({ optionName: 'Carousel', name: 'add_slide', }); - }, + } /** * Removes the current slide. * * @see this.selectClass for parameters. */ - removeSlide: function (previewMode) { + removeSlide(previewMode) { const $items = this.$carousel.find('.carousel-item'); const newLength = $items.length - 1; if (!this.removing && newLength > 0) { @@ -2245,13 +2219,13 @@ options.registry.CarouselItem = options.Class.extend({ this.removing = true; this.$carousel.carousel('prev'); } - }, + } /** * Goes to next slide or previous slide. * * @see this.selectClass for parameters */ - switchToSlide: function (previewMode, widgetValue, params) { + switchToSlide(previewMode, widgetValue, params) { switch (widgetValue) { case 'left': this.$controls.filter('.carousel-control-prev')[0].click(); @@ -2260,21 +2234,30 @@ options.registry.CarouselItem = options.Class.extend({ this.$controls.filter('.carousel-control-next')[0].click(); break; } - }, + } +} +registerWebsiteOption("CarouselItem", { + Class: CarouselItem, + template: "website.CarouselItem", + selector: ".s_carousel .carousel-item, .s_quotes_carousel .carousel-item", }); -options.registry.Parallax = options.Class.extend({ +class Parallax extends SnippetOption { /** * @override */ - async start() { + async willStart() { this.parallaxEl = this.$target.find('> .s_parallax_bg')[0] || null; - this._updateBackgroundOptions(); + // Delay the notify that changes the target because options that handle + // the target might not be initialized yet. + this.env.snippetEditionRequest(() => { + this._updateBackgroundOptions(); + }); this.$target.on('content_changed.ParallaxOption', this._onExternalUpdate.bind(this)); - return this._super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override */ @@ -2286,20 +2269,20 @@ options.registry.Parallax = options.Class.extend({ if (this.parallaxEl) { this._refreshPublicWidgets(); } - }, + } /** * @override */ onMove() { this._refreshPublicWidgets(); - }, + } /** * @override */ destroy() { - this._super(...arguments); + super.destroy(); this.$target.off('.ParallaxOption'); - }, + } //-------------------------------------------------------------------------- // Options @@ -2311,7 +2294,7 @@ options.registry.Parallax = options.Class.extend({ * @see this.selectClass for parameters */ async selectDataAttribute(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectDataAttribute(...arguments); if (params.attributeName !== 'scrollBackgroundRatio') { return; } @@ -2334,7 +2317,7 @@ options.registry.Parallax = options.Class.extend({ } this._updateBackgroundOptions(); - }, + } //-------------------------------------------------------------------------- // Private @@ -2345,7 +2328,7 @@ options.registry.Parallax = options.Class.extend({ */ async _computeVisibility(widgetName) { return !this.$target.hasClass('o_background_video'); - }, + } /** * @override */ @@ -2363,8 +2346,8 @@ options.registry.Parallax = options.Class.extend({ } } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Updates external background-related option to work with the parallax * element instead of the original target when necessary. @@ -2372,12 +2355,12 @@ options.registry.Parallax = options.Class.extend({ * @private */ _updateBackgroundOptions() { - this.trigger_up('option_update', { + this.callbacks.notifyOptions({ optionNames: ['BackgroundImage', 'BackgroundPosition', 'BackgroundOptimize'], name: 'target', data: this.parallaxEl ? $(this.parallaxEl) : this.$target, }); - }, + } //-------------------------------------------------------------------------- // Handlers @@ -2404,37 +2387,37 @@ options.registry.Parallax = options.Class.extend({ widget.enable(); widget.getParent().close(); // FIXME remove this ugly hack asap } - }, -}); + } +} -options.registry.collapse = options.Class.extend({ +export class BSCollapse extends SnippetOption { /** * @override */ - start: function () { + async willStart() { var self = this; this.$bsTarget.on('shown.bs.collapse hidden.bs.collapse', '[role="tabpanel"]', function () { - self.trigger_up('cover_update'); + self.callbacks.coverUpdate(); self.$target.trigger('content_changed'); }); - return this._super.apply(this, arguments); - }, + return super.willStart(...arguments); + } /** * @override */ - onBuilt: function () { + onBuilt() { this._createIDs(); - }, + } /** * @override */ - onClone: function () { + onClone() { this._createIDs(); - }, + } /** * @override */ - onMove: function () { + onMove() { this._createIDs(); var $panel = this.$bsTarget.find('.collapse').removeData('bs.collapse'); if ($panel.attr('aria-expanded') === 'true') { @@ -2445,7 +2428,7 @@ options.registry.collapse = options.Class.extend({ $panel.trigger('shown.bs.collapse'); }); } - }, + } //-------------------------------------------------------------------------- // Private @@ -2456,7 +2439,7 @@ options.registry.collapse = options.Class.extend({ * * @private */ - _createIDs: function () { + _createIDs() { let time = new Date().getTime(); const $tablist = this.$target.closest('[role="tablist"]'); const $tab = this.$target.find('[role="tab"]'); @@ -2484,7 +2467,13 @@ options.registry.collapse = options.Class.extend({ $tab.data('bs-target', '#' + panelId); $tab[0].setAttribute("aria-controls", panelId); - }, + } +} + +registerWebsiteOption("Accordion", { + Class: BSCollapse, + selector: ".accordion > .card", + dropIn: ".accordion:has(> .card)", }); options.registry.HeaderElements = options.Class.extend({ @@ -2760,8 +2749,8 @@ options.registry.topMenuColor = options.Class.extend({ /** * Manage the visibility of snippets on mobile/desktop. */ -options.registry.DeviceVisibility = options.Class.extend({ - +options.registry.DeviceVisibility = options.Class.extend({}); +export class DeviceVisibility extends SnippetOption { //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- @@ -2786,14 +2775,14 @@ options.registry.DeviceVisibility = options.Class.extend({ // Update invisible elements. const isMobile = wUtils.isMobile(this); - this.trigger_up('snippet_option_visibility_update', {show: widgetValue !== (isMobile ? 'no_mobile' : 'no_desktop')}); - }, + this.callbacks.updateSnippetOptionVisibility(widgetValue !== (isMobile ? 'no_mobile' : 'no_desktop')); + } /** * @override */ async onTargetHide() { this.$target[0].classList.remove('o_snippet_override_invisible'); - }, + } /** * @override */ @@ -2805,13 +2794,13 @@ options.registry.DeviceVisibility = options.Class.extend({ ) && isMobilePreview === isMobileHidden) { this.$target[0].classList.add('o_snippet_override_invisible'); } - }, + } /** * @override */ cleanForSave() { this.$target[0].classList.remove('o_snippet_override_invisible'); - }, + } //-------------------------------------------------------------------------- // Private @@ -2832,8 +2821,8 @@ options.registry.DeviceVisibility = options.Class.extend({ } return ''; } - return await this._super(...arguments); - }, + return await super._computeWidgetState(...arguments); + } /** * @override */ @@ -2841,8 +2830,14 @@ options.registry.DeviceVisibility = options.Class.extend({ if (this.$target[0].classList.contains('s_table_of_content_main')) { return false; } - return this._super(...arguments); + return super._computeWidgetVisibility(...arguments); } +} +registerWebsiteOption("DeviceVisibility", { + Class: DeviceVisibility, + template: "website.DeviceVisibility", + selector: "section .row > div", + exclude: ".s_col_no_resize.row > div, .s_masonry_block .s_col_no_resize", }); /** @@ -3142,24 +3137,16 @@ options.registry.CookiesBar = options.registry.SnippetPopup.extend({ * Allows edition of 'cover_properties' in website models which have such * fields (blogs, posts, events, ...). */ -options.registry.CoverProperties = options.Class.extend({ +export class CoverProperties extends SnippetOption { /** * @constructor */ - init: function () { - this._super.apply(this, arguments); + constructor() { + super(...arguments); this.$image = this.$target.find('.o_record_cover_image'); this.$filter = this.$target.find('.o_record_cover_filter'); - }, - /** - * @override - */ - start: function () { - this.$filterValueOpts = this.$el.find('[data-filter-value]'); - - return this._super.apply(this, arguments); - }, + } //-------------------------------------------------------------------------- // Options @@ -3170,7 +3157,7 @@ options.registry.CoverProperties = options.Class.extend({ * * @see this.selectClass for parameters */ - background: async function (previewMode, widgetValue, params) { + async background(previewMode, widgetValue, params) { if (previewMode === false) { this.$image[0].classList.remove("o_b64_image_to_save"); } @@ -3198,46 +3185,45 @@ options.registry.CoverProperties = options.Class.extend({ } this.$image.css('background-image', `url('${widgetValue}')`); this.$target.addClass('o_record_has_cover'); - const $defaultSizeBtn = this.$el.find('.o_record_cover_opt_size_default'); - $defaultSizeBtn.click(); - $defaultSizeBtn.closest('we-select').click(); + // TODO: @owl-options Obviously wrong because it impacts previewMode - but kept as it was + this.findWidget("record_cover_default_size_opt").enable(); } if (!previewMode) { this._updateSavingDataset(); } - }, + } /** * @see this.selectClass for parameters */ - filterValue: function (previewMode, widgetValue, params) { + filterValue(previewMode, widgetValue, params) { this.$filter.css('opacity', widgetValue || 0); this.$filter.toggleClass('oe_black', parseFloat(widgetValue) !== 0); if (!previewMode) { this._updateSavingDataset(); } - }, + } /** * @override */ - selectStyle: async function (previewMode, widgetValue, params) { - await this._super(...arguments); + async selectStyle(previewMode, widgetValue, params) { + await super.selectStyle(...arguments); if (!previewMode) { this._updateSavingDataset(widgetValue); } - }, + } /** * @override */ - selectClass: async function (previewMode, widgetValue, params) { - await this._super(...arguments); + async selectClass(previewMode, widgetValue, params) { + await super.selectClass(...arguments); if (!previewMode) { this._updateSavingDataset(); } - }, + } //-------------------------------------------------------------------------- // Private @@ -3246,7 +3232,7 @@ options.registry.CoverProperties = options.Class.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'filterValue': { return parseFloat(this.$filter.css('opacity')).toFixed(1); @@ -3259,24 +3245,24 @@ options.registry.CoverProperties = options.Class.extend({ return ''; } } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ - _computeWidgetVisibility: function (widgetName, params) { + _computeWidgetVisibility(widgetName, params) { if (params.coverOptName) { return this.$target.data(`use_${params.coverOptName}`) === 'True'; } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } /** * @private */ _updateColorDataset(bgColorStyle = '', bgColorClass = '') { this.$target[0].dataset.bgColorStyle = bgColorStyle; this.$target[0].dataset.bgColorClass = bgColorClass; - }, + } /** * Updates the cover properties dataset used for saving. * @@ -3308,8 +3294,8 @@ options.registry.CoverProperties = options.Class.extend({ this.$target[0].dataset.filterValue = filterValue || 0.0; // TODO there is probably a better way and this should be refactored to // use more standard colorpicker+imagepicker structure - const ccValue = colorPickerWidget._ccValue; - const colorOrGradient = colorPickerWidget._value; + const ccValue = colorPickerWidget._state.ccValue; + const colorOrGradient = colorPickerWidget._state.value; const isGradient = weUtils.isColorGradient(colorOrGradient); const valueIsCSSColor = !isGradient && isCSSColor(colorOrGradient); const colorNames = []; @@ -3323,17 +3309,24 @@ options.registry.CoverProperties = options.Class.extend({ const bgColorStyle = valueIsCSSColor ? `background-color: ${colorOrGradient};` : isGradient ? `background-color: rgba(0, 0, 0, 0); background-image: ${colorOrGradient};` : ''; this._updateColorDataset(bgColorStyle, bgColorClass); - }, + } +} + +registerWebsiteOption("CoverProperties", { + Class: CoverProperties, + template: "website.cover_properties_option", + selector: ".o_record_cover_container", + noCheck: true, + withColorCombinations: true, + withGradients: true, }); -options.registry.ScrollButton = options.Class.extend({ - /** - * @override - */ - start: async function () { - await this._super(...arguments); - this.$button = this.$('.o_scroll_button'); - }, + +class ScrollButton extends SnippetOption { + constructor() { + super(...arguments); + this.$button = this.$target.find('.o_scroll_button'); + } //-------------------------------------------------------------------------- // Options @@ -3352,11 +3345,11 @@ options.registry.ScrollButton = options.Class.extend({ this.$button.detach(); } } - }, + } /** * Toggles the scroll down button. */ - toggleButton: function (previewMode, widgetValue, params) { + toggleButton(previewMode, widgetValue, params) { if (widgetValue) { if (!this.$button.length) { const anchor = document.createElement('a'); @@ -3382,12 +3375,12 @@ options.registry.ScrollButton = options.Class.extend({ } else { this.$button.detach(); } - }, + } /** * @override */ async selectClass(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectClass(...arguments); // If a "d-lg-block" class exists on the section (e.g., for mobile // visibility option), it should be replaced with a "d-lg-flex" class. // This ensures that the section has the "display: flex" property @@ -3404,7 +3397,7 @@ options.registry.ScrollButton = options.Class.extend({ this.$target[0].classList.add(hasDisplayFlex ? "d-lg-flex" : "d-lg-block"); } } - }, + } //-------------------------------------------------------------------------- // Private @@ -3420,17 +3413,17 @@ options.registry.ScrollButton = options.Class.extend({ const minHeightEl = uiFragment.querySelector('[data-name="minheight_auto_opt"]'); minHeightEl.parentElement.setAttribute('string', _t("Min-Height")); } - }, + } /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { switch (methodName) { case 'toggleButton': return !!this.$button.parent().length; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @override */ @@ -3438,59 +3431,76 @@ options.registry.ScrollButton = options.Class.extend({ if (widgetName === 'fixed_height_opt') { return (this.$target[0].dataset.snippet === 's_image_gallery'); } - return this._super(...arguments); - }, + return super._computeWidgetVisibility(...arguments); + } +} +registerWebsiteOption("ScrollButton", { + Class: ScrollButton, + template: "website.scroll_button_option", + selector: "section", + exclude: "[data-snippet] :not(.oe_structure) > [data-snippet], .s_instagram_page", }); -options.registry.ConditionalVisibility = options.registry.DeviceVisibility.extend({ - /** - * @constructor - */ - init() { - this._super(...arguments); + +options.registry.ConditionalVisibility = options.registry.DeviceVisibility.extend({}); +class ConditionalVisibilityComponent extends SnippetOptionComponent { + setup() { + super.setup(...arguments); + + onMounted(() => { + for (const widget of Object.values(this.props.snippetOption.instance._userValues)) { + const params = widget.getMethodsParams(); + if (params.saveAttribute) { + this.props.snippetOption.instance.optionsAttributes.push({ + saveAttribute: params.saveAttribute, + attributeName: params.attributeName, + // If callWith dataAttribute is not specified, the default + // field to check on the record will be .value for values + // coming from another widget than M2M. + callWith: params.callWith || 'value', + }); + } + } + }); + } +} +class ConditionalVisibility extends DeviceVisibility { + static defaultRenderingComponent = ConditionalVisibilityComponent; + + constructor() { + super(...arguments); this.optionsAttributes = []; - }, + this.orm = serviceCached(this.env, "orm"); + } /** * @override */ - async start() { - await this._super(...arguments); - - for (const widget of this._userValueWidgets) { - const params = widget.getMethodsParams(); - if (params.saveAttribute) { - this.optionsAttributes.push({ - saveAttribute: params.saveAttribute, - attributeName: params.attributeName, - // If callWith dataAttribute is not specified, the default - // field to check on the record will be .value for values - // coming from another widget than M2M. - callWith: params.callWith || 'value', - }); - } - } - }, + async _getRenderContext() { + const context = await super._getRenderContext(...arguments); + context.countryCode = session.geoip_country_code; + context.currentWebsite = (await this.orm.searchRead( + "website", + [["id", "=", this.env.services.website.currentWebsite.id]], + ["language_ids"] + ))[0]; + return context; + } /** * @override */ async onTargetHide() { - await this._super(...arguments); + await super.onTargetHide(...arguments); if (this.$target[0].classList.contains('o_snippet_invisible')) { this.$target[0].classList.add('o_conditional_hidden'); } - }, + } /** * @override */ async onTargetShow() { - await this._super(...arguments); + await super.onTargetShow(...arguments); this.$target[0].classList.remove('o_conditional_hidden'); - }, - // Todo: remove me in master. - /** - * @override - */ - cleanForSave() {}, + } //-------------------------------------------------------------------------- // Options @@ -3511,7 +3521,7 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten } this._updateCSSSelectors(); - }, + } /** * Selects a value for target's data-attributes. * Should be used instead of selectRecord if the visibility is not related @@ -3529,21 +3539,18 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten } this._updateCSSSelectors(); - }, + } /** * Opens the toggler when 'conditional' is selected. * * @override */ async selectDataAttribute(previewMode, widgetValue, params) { - await this._super(...arguments); + await super.selectDataAttribute(...arguments); if (params.attributeName === 'visibility') { const targetEl = this.$target[0]; - if (widgetValue === 'conditional') { - const collapseEl = this.$el.children('we-collapse')[0]; - this._toggleCollapseEl(collapseEl); - } else { + if (widgetValue !== 'conditional') { // TODO create a param to allow doing this automatically for genericSelectDataAttribute? delete targetEl.dataset.visibility; @@ -3552,13 +3559,13 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten delete targetEl.dataset[`${attribute.saveAttribute}Rule`]; } } - this.trigger_up('snippet_option_visibility_update', {show: true}); + this.callbacks.updateSnippetOptionVisibility(true); } else if (!params.isVisibilityCondition) { return; } this._updateCSSSelectors(); - }, + } //-------------------------------------------------------------------------- // Private @@ -3575,8 +3582,8 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten const selectedValue = this.$target[0].dataset[params.saveAttribute]; return selectedValue ? JSON.parse(selectedValue)[0].value : params.attributeDefaultValue; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * Reads target's attributes and creates CSS selectors. * Stores them in data-attributes to then be reapplied by @@ -3651,7 +3658,12 @@ options.registry.ConditionalVisibility = options.registry.DeviceVisibility.exten } else { delete this.$target[0].dataset.visibilityId; } - }, + } +} +registerWebsiteOption("ConditionalVisibility", { + Class: ConditionalVisibility, + template: "website.ConditionalVisibility", + selector: "section, .s_hr", }); options.registry.WebsiteAnimate = options.Class.extend({ @@ -4069,14 +4081,16 @@ options.registry.TextHighlight = options.Class.extend({ /** * Replaces current target with the specified template layout */ -options.registry.MegaMenuLayout = options.registry.SelectTemplate.extend({ +export class MegaMenuLayout extends SelectTemplate { /** * @override */ - init() { - this._super(...arguments); + static forceNoDeleteButton = true; + + constructor() { + super(...arguments); this.selectTemplateWidgetName = 'mega_menu_template_opt'; - }, + } //-------------------------------------------------------------------------- // Public @@ -4093,9 +4107,9 @@ options.registry.MegaMenuLayout = options.registry.SelectTemplate.extend({ data.onSuccess(); }); } else { - this._super(...arguments); + super.notify(...arguments); } - }, + } //-------------------------------------------------------------------------- // Private @@ -4104,28 +4118,36 @@ options.registry.MegaMenuLayout = options.registry.SelectTemplate.extend({ /** * @override */ - _computeWidgetState: function (methodName, params) { + _computeWidgetState(methodName, params) { if (methodName === 'selectTemplate') { return this._getCurrentTemplateXMLID(); } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } /** * @private * @returns {string} xmlid of the current template. */ - _getCurrentTemplateXMLID: function () { + _getCurrentTemplateXMLID() { const templateDefiningClass = this.containerEl.querySelector('section') .classList.value.split(' ').filter(cl => cl.startsWith('s_mega_menu'))[0]; return `website.${templateDefiningClass}`; - }, + } +} +registerWebsiteOption("MegaMenuLayout", { + Class: MegaMenuLayout, + template: "web_editor.mega_menu_layout_options", + selector: ".o_mega_menu", }); /** * Hides delete and clone buttons for Mega Menu block. */ -options.registry.MegaMenuNoDelete = options.Class.extend({ - forceNoDeleteButton: true, +export class MegaMenuNoDelete extends SnippetOption { + /** + * @override + */ + static forceNoDeleteButton = true; /** * @override @@ -4140,7 +4162,16 @@ options.registry.MegaMenuNoDelete = options.Class.extend({ } }); }); - }, + } +} +registerWebsiteOption("MegaMenuNoDelete", { + Class: MegaMenuLayout, + selector: ".o_mega_menu > section", +}); +registerWebsiteOption("MegaMenuNoDeleteDrop", { + selector: ".o_mega_menu .nav > .nav-link", + dropIn: ".o_mega_menu nav", + dropNear: () => ".o_mega_menu .nav-link", }); options.registry.sizing.include({ @@ -4197,48 +4228,51 @@ options.registry.sizing.include({ }, }); -options.registry.SwitchableViews = options.Class.extend({ +export class SwitchableViews extends SnippetOption { /** * @override */ async willStart() { - const _super = this._super.bind(this); this.switchableRelatedViews = await new Promise((resolve, reject) => { - this.trigger_up('get_switchable_related_views', { + this.env.getSwitchableRelatedViews({ onSuccess: resolve, onFailure: reject, }); }); - return _super(...arguments); - }, + return super.willStart(...arguments); + } /** * @override */ - _renderCustomXML(uiFragment) { - for (const view of this.switchableRelatedViews) { - const weCheckboxEl = document.createElement('we-checkbox'); - weCheckboxEl.setAttribute('string', view.name); - weCheckboxEl.setAttribute('data-customize-website-views', view.key); - weCheckboxEl.setAttribute('data-no-preview', 'true'); - weCheckboxEl.setAttribute('data-reload', '/'); - uiFragment.appendChild(weCheckboxEl); - } - }, + async _getRenderContext() { + return { + switchableRelatedViews: this.switchableRelatedViews, + }; + } /*** * @override */ _computeVisibility() { return !!this.switchableRelatedViews.length; - }, + } /** * @override */ _checkIfWidgetsUpdateNeedReload() { return true; } +} + +registerWebsiteOption("SwitchableViews", { + Class: SwitchableViews, + template: "website.switchable_views_option", + selector: "#wrapwrap > main", + noCheck: "true", + group: "website.group_website_designer", }); -options.registry.GridImage = options.Class.extend({ + +export class GridImage extends SnippetOption { //-------------------------------------------------------------------------- // Options @@ -4252,7 +4286,7 @@ options.registry.GridImage = options.Class.extend({ if (imageGridItemEl) { imageGridItemEl.classList.toggle('o_grid_item_image_contain', widgetValue === 'contain'); } - }, + } //-------------------------------------------------------------------------- // Private @@ -4266,7 +4300,7 @@ options.registry.GridImage = options.Class.extend({ */ _getImageGridItem() { return this.$target[0].closest(".o_grid_item_image"); - }, + } /** * @override */ @@ -4276,11 +4310,11 @@ options.registry.GridImage = options.Class.extend({ const effectAllowsOption = !["dolly_zoom", "outline", "image_mirror_blur"] .includes(this.$target[0].dataset.hoverEffect); - return this._super(...arguments) + return super._computeVisibility(...arguments) && !!this._getImageGridItem() && (!('shape' in this.$target[0].dataset) || hasSquareShape && effectAllowsOption); - }, + } /** * @override */ @@ -4291,12 +4325,18 @@ options.registry.GridImage = options.Class.extend({ ? 'contain' : 'cover'; } - return this._super(...arguments); - }, + return super._computeWidgetState(...arguments); + } +} + +registerWebsiteOption("GridImage", { + Class: GridImage, + template: "website.grid_image_option", + selector: "img", }); -options.registry.GalleryElement = options.Class.extend({ +class GalleryElement extends SnippetOption { //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- @@ -4310,7 +4350,7 @@ options.registry.GalleryElement = options.Class.extend({ const optionName = this.$target[0].classList.contains("carousel-item") ? "Carousel" : "GalleryImageList"; const itemEl = this.$target[0]; - this.trigger_up("option_update", { + this.callbacks.notifyOptions({ optionName: optionName, name: "reorder_items", data: { @@ -4318,19 +4358,25 @@ options.registry.GalleryElement = options.Class.extend({ position: widgetValue, }, }); - }, -}); + } +} +registerWebsiteOption("GalleryElement", { + Class: GalleryElement, + template: "website.GalleryElement", + selector: ".s_image_gallery img, .s_carousel .carousel-item", +}, { sequence: 10 }); + -options.registry.Button = options.Class.extend({ +export class Button extends SnippetOption { /** * @override */ - init() { - this._super(...arguments); + constructor() { + super(...arguments); const isUnremovableButton = this.$target[0].classList.contains("oe_unremovable"); this.forceDuplicateButton = !isUnremovableButton; this.forceNoDeleteButton = isUnremovableButton; - }, + } /** * @override */ @@ -4341,7 +4387,7 @@ options.registry.Button = options.Class.extend({ if (options.isCurrent) { this._adaptButtons(); } - }, + } /** * @override */ @@ -4351,7 +4397,7 @@ options.registry.Button = options.Class.extend({ if (options.isCurrent) { this._adaptButtons(false); } - }, + } //-------------------------------------------------------------------------- // Private @@ -4417,28 +4463,148 @@ options.registry.Button = options.Class.extend({ } this.$target[0].classList.remove("s_custom_button"); } - }, + } +} + +registerWebsiteOption("Button", { + Class: Button, + selector: "a.btn", + exclude: "so_submit_button_selector", }); -options.registry.layout_column.include({ +class WebsiteLayoutColumn extends LayoutColumn { /** * @override */ _isMobile() { - return wUtils.isMobile(this); - }, + return this.env.services.website.context.isMobile; + } +} + +registerWebsiteOption("GridColumns", { + Class: GridColumns, + template: "website.grid_columns_option", + selector: ".row:not(.s_col_no_resize) > div", +}); + +registerWebsiteOption("WebsiteLayoutColumns", { + Class: WebsiteLayoutColumn, + template: "website.layout_column", + selector: "section, section.s_carousel_wrapper .carousel-item", + target: "> *:has(> .row), > .s_allow_columns", + exclude: ".s_masonry_block, .s_features_grid, .s_media_list, .s_table_of_content, .s_process_steps, .s_image_gallery" +}, { sequence: 15 }); + tags: ["website"], + + +registerWebsiteOption("card_color_border_shadow", { + Class: Box, + template: "website.card_color_border_shadow", + selector: ".s_three_columns .row > div, .s_comparisons .row > div", + target: ".card", +}); + +registerWebsiteOption("card_color", { + template: "website.card_color", + selector: ".accordion .card", +}); + +registerWebsiteOption("horizontal_alignment", { + template: "website.horizontal_alignment_option", + selector: ".s_share, .s_text_highlight, .s_social_media", }); -options.registry.SnippetMove.include({ +registerWebsiteOption("vertical_alignment", { + class: vAlignment, + template: "website.vertical_alignment_option", + selector: ".s_text_image, .s_image_text, .s_three_columns", + target: ".row", +}); + +registerWebsiteOption("share_social_media", { + template: "website.share_social_media_option", + selector: ".s_share, .s_social_media", +}); + +patch(SnippetMove.prototype, { /** * @override */ _isMobile() { - return wUtils.isMobile(this); + return this.env.services.website.context.isMobile; }, }); -export default { - UrlPickerUserValueWidget: UrlPickerUserValueWidget, - FontFamilyPickerUserValueWidget: FontFamilyPickerUserValueWidget, -}; +export function websiteRegisterBackgroundOptions(key, options) { + options.module = "website"; + registerBackgroundOptions(key, options, (name) => name === "toggler" && "website.snippet_options_background_options"); + if (options.withVideos) { + registerWebsiteOption(`${key}-bgVideo`, { + Class: BackgroundVideo, + template: "website.BackgroundVideo", + ...options, + }, { sequence: 30 }); + } + if (options.withImages) { + registerWebsiteOption(`${key}-parallax`, { + Class: Parallax, + template: "website.Parallax", + ...options, + }, { sequence: 30 }); + } + +} + +export const onlyBgColorSelector = "section .row > div, .s_text_highlight, .s_mega_menu_thumbnails_footer, .s_hr"; +export const onlyBgColorExclude = ".s_col_no_bgcolor, .s_col_no_bgcolor.row > div, .s_masonry_block .row > div, .s_color_blocks_2 .row > div, .s_image_gallery .row > div, .s_text_cover .row > .o_not_editable, [data-snippet] :not(.oe_structure) > .s_hr"; +export const baseOnlyBgImageSelector = ".s_tabs .oe_structure > *, footer .oe_structure > *"; +export const onlyBgImageSelector = baseOnlyBgImageSelector; +export const onlyBgImageExclude = ""; +export const bothBgColorImageSelector = "section, .carousel-item, .s_masonry_block .row > div, .s_color_blocks_2 .row > div, .parallax, .s_text_cover .row > .o_not_editable"; +export const bothBgColorImageExclude = baseOnlyBgImageSelector + ", .s_carousel_wrapper, .s_image_gallery .carousel-item, .s_google_map, .s_map, [data-snippet] :not(.oe_structure) > [data-snippet], .s_masonry_block .s_col_no_resize"; + +websiteRegisterBackgroundOptions("BothBgImage", { + selector: bothBgColorImageSelector, + exclude: bothBgColorImageExclude, + withColors: true, + withImages: true, + withVideos: true, + withShapes: true, + withColorCombinations: true, + withGradients: true, +}); + +websiteRegisterBackgroundOptions("OnlyBgColor", { + selector: onlyBgColorSelector, + exclude: onlyBgColorExclude, + withColors: true, + withImages: false, + withColorCombinations: true, + withGradients: true, +}); + +websiteRegisterBackgroundOptions("OnlyBgImage", { + selector: onlyBgImageSelector, + exclude: onlyBgImageExclude, + withColors: false, + withImages: true, + withVideos: true, + withShapes: true, +}); + +registerWebsiteOption("ColumnsOnly", { + Class: WebsiteLayoutColumn, + template: "website.columns_only", + selector: "section.s_features_grid, section.s_process_steps", + target: "> *:has(> .row), > .s_allow_columns", +}, { sequence: 15 }); + +// TODO: @owl-options What to do with those ? +let so_submit_button_selector = ".s_donation_donate_btn, .s_website_form_send"; + +registerWebsiteOption("SnippetSave", { + Class: SnippetSave, + template: "website.snippet_save_option", + selector: "[data-snippet], a.btn", + exclude: `.o_no_save, ${so_submit_button_selector}`, +}); diff --git a/addons/website/static/src/js/editor/snippets.options.legacy.js b/addons/website/static/src/js/editor/snippets.options.legacy.js new file mode 100644 index 0000000000000..ee73a1f5463f2 --- /dev/null +++ b/addons/website/static/src/js/editor/snippets.options.legacy.js @@ -0,0 +1,4224 @@ +/** @odoo-module **/ + +import { loadCSS } from "@web/core/assets"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { Dialog } from "@web/core/dialog/dialog"; +import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; +import { useChildRef } from "@web/core/utils/hooks"; +import weUtils from "@web_editor/js/common/utils"; +import options from "@web_editor/js/editor/snippets.options.legacy"; +import { NavbarLinkPopoverWidget } from "@website/js/widgets/link_popover_widget"; +import wUtils from "@website/js/utils"; +import { + applyModifications, + isImageCorsProtected, + isImageSupportedForStyle, + loadImageInfo, +} from "@web_editor/js/editor/image_processing"; +import "@website/snippets/s_popup/options"; +import { range } from "@web/core/utils/numbers"; +import { _t } from "@web/core/l10n/translation"; +import {Domain} from "@web/core/domain"; +import { + isCSSColor, + convertCSSColorToRgba, + convertRgbaToCSSColor, + convertRgbToHsl, + convertHslToRgb, + } from '@web/core/utils/colors'; +import { renderToElement, renderToFragment } from "@web/core/utils/render"; +import { browser } from "@web/core/browser/browser"; +import { + removeTextHighlight, + drawTextHighlightSVG, +} from "@website/js/text_processing"; + +import { Component, markup, useRef, useState } from "@odoo/owl"; + +const InputUserValueWidget = options.userValueWidgetsRegistry['we-input']; +const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select']; +const Many2oneUserValueWidget = options.userValueWidgetsRegistry['we-many2one']; + +options.UserValueWidget.include({ + loadMethodsData() { + this._super(...arguments); + + // Method names are sorted alphabetically by default. Exception here: + // we make sure, customizeWebsiteVariable is considered after + // customizeWebsiteViews so that the variable is used to show to active + // value when both methods are used at the same time. + // TODO find a better way. + const indexVariable = this._methodsNames.indexOf('customizeWebsiteVariable'); + if (indexVariable >= 0) { + const indexView = this._methodsNames.indexOf('customizeWebsiteViews'); + if (indexView >= 0) { + this._methodsNames[indexVariable] = 'customizeWebsiteViews'; + this._methodsNames[indexView] = 'customizeWebsiteVariable'; + } + } + }, +}); + +Many2oneUserValueWidget.include({ + init() { + this._super(...arguments); + this.fields = this.bindService("field"); + }, + + /** + * @override + */ + async _getSearchDomain() { + // Add the current website's domain if the model has a website_id field. + // Note that the `_rpc` method is cached in Many2X user value widget, + // see `_rpcCache`. + const websiteIdField = await this.fields.loadFields(this.options.model, { + fieldNames: ["website_id"], + }); + const modelHasWebsiteId = !!websiteIdField["website_id"]; + if (modelHasWebsiteId && !this.options.domain.find(arr => arr[0] === "website_id")) { + this.options.domain = + Domain.and([this.options.domain, wUtils.websiteDomain(this)]).toList(); + } + return this.options.domain; + }, +}); + +const UrlPickerUserValueWidget = InputUserValueWidget.extend({ + events: Object.assign({}, InputUserValueWidget.prototype.events || {}, { + 'click .o_we_redirect_to': '_onRedirectTo', + }), + + /** + * @override + */ + start: async function () { + await this._super(...arguments); + const linkButton = document.createElement('we-button'); + const icon = document.createElement('i'); + icon.classList.add('fa', 'fa-fw', 'fa-external-link'); + linkButton.classList.add('o_we_redirect_to', 'o_we_link', 'ms-1'); + linkButton.title = _t("Preview this URL in a new tab"); + linkButton.appendChild(icon); + this.containerEl.after(linkButton); + this.el.classList.add('o_we_large'); + this.inputEl.classList.add('text-start'); + const options = { + classes: { + "ui-autocomplete": 'o_website_ui_autocomplete' + }, + body: this.getParent().$target[0].ownerDocument.body, + urlChosen: this._onWebsiteURLChosen.bind(this), + }; + this.unmountAutocompleteWithPages = wUtils.autocompleteWithPages(this.inputEl, options); + }, + + open() { + this._super(...arguments); + document.querySelector(".o_website_ui_autocomplete")?.classList?.remove("d-none"); + }, + + close() { + this._super(...arguments); + document.querySelector(".o_website_ui_autocomplete")?.classList?.add("d-none"); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the autocomplete change the input value. + * + * @private + * @param {OdooEvent} ev + */ + _onWebsiteURLChosen: function (ev) { + this._value = this.inputEl.value; + this._onUserValueChange(ev); + }, + /** + * Redirects to the URL the widget currently holds. + * + * @private + */ + _onRedirectTo: function () { + if (this._value) { + window.open(this._value, '_blank'); + } + }, + destroy() { + this.unmountAutocompleteWithPages?.(); + this.unmountAutocompleteWithPages = null; + this._super(...arguments); + } +}); + +const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ + events: Object.assign({}, SelectUserValueWidget.prototype.events || {}, { + 'click .o_we_add_google_font_btn': '_onAddGoogleFontClick', + 'click .o_we_delete_google_font_btn': '_onDeleteGoogleFontClick', + }), + fontVariables: [], // Filled by editor menu when all options are loaded + + /** + * @override + */ + init() { + this.dialog = this.bindService("dialog"); + return this._super(...arguments); + }, + /** + * @override + */ + start: async function () { + const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); + const nbFonts = parseInt(weUtils.getCSSVariableValue('number-of-fonts', style)); + // User fonts served by google server. + const googleFontsProperty = weUtils.getCSSVariableValue('google-fonts', style); + this.googleFonts = googleFontsProperty ? googleFontsProperty.split(/\s*,\s*/g) : []; + this.googleFonts = this.googleFonts.map(font => font.substring(1, font.length - 1)); // Unquote + // Local user fonts. + const googleLocalFontsProperty = weUtils.getCSSVariableValue('google-local-fonts', style); + this.googleLocalFonts = googleLocalFontsProperty ? + googleLocalFontsProperty.slice(1, -1).split(/\s*,\s*/g) : []; + // If a same font exists both remotely and locally, we remove the remote + // font to prioritize the local font. The remote one will never be + // displayed or loaded as long as the local one exists. + this.googleFonts = this.googleFonts.filter(font => { + const localFonts = this.googleLocalFonts.map(localFont => localFont.split(":")[0]); + return localFonts.indexOf(`'${font}'`) === -1; + }); + this.allFonts = []; + + await this._super(...arguments); + + const fontsToLoad = []; + for (const font of this.googleFonts) { + const fontURL = `https://fonts.googleapis.com/css?family=${encodeURIComponent(font).replace(/%20/g, '+')}`; + fontsToLoad.push(fontURL); + } + for (const font of this.googleLocalFonts) { + const attachmentId = font.split(/\s*:\s*/)[1]; + const fontURL = `/web/content/${encodeURIComponent(attachmentId)}`; + fontsToLoad.push(fontURL); + } + // TODO ideally, remove the elements created once this widget + // instance is destroyed (although it should not hurt to keep them for + // the whole backend lifecycle). + const proms = fontsToLoad.map(async fontURL => loadCSS(fontURL)); + const fontsLoadingProm = Promise.all(proms); + + const fontEls = []; + const methodName = this.el.dataset.methodName || 'customizeWebsiteVariable'; + const variable = this.el.dataset.variable; + const themeFontsNb = nbFonts - (this.googleLocalFonts.length + this.googleFonts.length); + for (let fontNb = 0; fontNb < nbFonts; fontNb++) { + const realFontNb = fontNb + 1; + const fontKey = weUtils.getCSSVariableValue(`font-number-${realFontNb}`, style); + this.allFonts.push(fontKey); + let fontName = fontKey.slice(1, -1); // Unquote + let fontFamily = fontName; + const isSystemFonts = fontName === "SYSTEM_FONTS"; + if (isSystemFonts) { + fontName = _t("System Fonts"); + fontFamily = 'var(--o-system-fonts)'; + } + const fontEl = document.createElement('we-button'); + fontEl.setAttribute('string', fontName); + fontEl.dataset.variable = variable; + fontEl.dataset[methodName] = fontKey; + fontEl.dataset.fontFamily = fontFamily; + if ((realFontNb <= themeFontsNb) && !isSystemFonts) { + // Add the "cloud" icon next to the theme's default fonts + // because they are served by Google. + fontEl.appendChild(Object.assign(document.createElement('i'), { + role: 'button', + className: 'text-info me-2 fa fa-cloud', + title: _t("This font is hosted and served to your visitors by Google servers"), + })); + } + fontEls.push(fontEl); + this.menuEl.appendChild(fontEl); + } + + if (this.googleLocalFonts.length) { + const googleLocalFontsEls = fontEls.splice(-this.googleLocalFonts.length); + googleLocalFontsEls.forEach((el, index) => { + $(el).append(renderToFragment('website.delete_google_font_btn', { + index: index, + local: "true", + })); + }); + } + + if (this.googleFonts.length) { + const googleFontsEls = fontEls.splice(-this.googleFonts.length); + googleFontsEls.forEach((el, index) => { + $(el).append(renderToFragment('website.delete_google_font_btn', { + index: index, + })); + }); + } + +/* + $(this.menuEl).append($(renderToElement('website.add_google_font_btn', { + variable: variable, + }))); +*/ + return fontsLoadingProm; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async setValue() { + await this._super(...arguments); + + this.menuTogglerEl.style.fontFamily = ''; + const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); + if (activeWidget) { + this.menuTogglerEl.style.fontFamily = activeWidget.el.dataset.fontFamily; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAddGoogleFontClick: function (ev) { + const addGoogleFontDialog = class extends Component { + static template = "website.dialog.addGoogleFont"; + static components = { Dialog }; + static props = { close: Function, title: String, onClickSave: Function }; + title = _t("Add a Google Font"); + state = useState({ valid: true, loading: false, googleServe: true }); + fontInput = useRef("fontInput"); + async onClickSave() { + if (this.state.loading) { + return; + } + this.state.loading = true; + const shouldClose = await this.props.onClickSave(this.state, this.fontInput.el); + if (shouldClose) { + this.props.close(); + return; + } + this.state.loading = false; + } + onClickCancel() { + this.props.close(); + } + }; + const variable = $(ev.currentTarget).data('variable'); + this.dialog.add(addGoogleFontDialog, { + title: _t("Add a Google Font"), + onClickSave: async (state, inputEl) => { + // if font page link (what is expected) + let m = inputEl.value.match(/\bspecimen\/([\w+]+)/); + if (!m) { + // if embed code (so that it works anyway if the user put the embed code instead of the page link) + m = inputEl.value.match(/\bfamily=([\w+]+)/); + if (!m) { + inputEl.classList.add('is-invalid'); + return; + } + } + + let isValidFamily = false; + + try { + // Font family is an encoded query parameter: + // "Open+Sans" needs to remain "Open+Sans". + const result = await fetch("https://fonts.googleapis.com/css?family=" + m[1] + ':300,300i,400,400i,700,700i', {method: 'HEAD'}); + // Google fonts server returns a 400 status code if family is not valid. + if (result.ok) { + isValidFamily = true; + } + } catch (error) { + console.error(error); + } + + if (!isValidFamily) { + inputEl.classList.add('is-invalid'); + return; + } + + const font = m[1].replace(/\+/g, ' '); + const googleFontServe = state.googleServe; + const fontName = `'${font}'`; + // If the font already exists, it will only be added if + // the user chooses to add it locally when it is already + // imported from the Google Fonts server. + const fontExistsLocally = this.googleLocalFonts.some(localFont => localFont.split(':')[0] === fontName); + const fontExistsOnServer = this.allFonts.includes(fontName); + const preventFontAddition = fontExistsLocally || (fontExistsOnServer && googleFontServe); + if (preventFontAddition) { + inputEl.classList.add('is-invalid'); + // Show custom validity error message. + inputEl.setCustomValidity(_t("This font already exists, you can only add it as a local font to replace the server version.")); + inputEl.reportValidity(); + return; + } + if (googleFontServe) { + this.googleFonts.push(font); + } else { + this.googleLocalFonts.push(`'${font}': ''`); + } + this.trigger_up('google_fonts_custo_request', { + values: {[variable]: `'${font}'`}, + googleFonts: this.googleFonts, + googleLocalFonts: this.googleLocalFonts, + }); + return true; + }, + }); + }, + /** + * @private + * @param {Event} ev + */ + _onDeleteGoogleFontClick: async function (ev) { + ev.preventDefault(); + const values = {}; + + const save = await new Promise(resolve => { + this.dialog.add(ConfirmationDialog, { + body: _t("Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?"), + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + if (!save) { + return; + } + + // Remove Google font + const googleFontIndex = parseInt(ev.target.dataset.fontIndex); + const isLocalFont = ev.target.dataset.localFont; + let googleFontName; + if (isLocalFont) { + const googleFont = this.googleLocalFonts[googleFontIndex].split(':'); + // Remove double quotes + googleFontName = googleFont[0].substring(1, googleFont[0].length - 1); + values['delete-font-attachment-id'] = googleFont[1]; + this.googleLocalFonts.splice(googleFontIndex, 1); + } else { + googleFontName = this.googleFonts[googleFontIndex]; + this.googleFonts.splice(googleFontIndex, 1); + } + + // Adapt font variable indexes to the removal + const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); + FontFamilyPickerUserValueWidget.prototype.fontVariables.forEach((variable) => { + const value = weUtils.getCSSVariableValue(variable, style); + if (value.substring(1, value.length - 1) === googleFontName) { + // If an element is using the google font being removed, reset + // it to the theme default. + values[variable] = 'null'; + } + }); + + this.trigger_up('google_fonts_custo_request', { + values: values, + googleFonts: this.googleFonts, + googleLocalFonts: this.googleLocalFonts, + }); + }, +}); + +const GPSPicker = InputUserValueWidget.extend({ + // Explicitly not consider all InputUserValueWidget events. E.g. we actually + // don't want input focusout messing with the google map API. Because of + // this, clicking on google map autocomplete suggestion on Firefox was not + // working properly. + events: {}, + + /** + * @constructor + */ + init() { + this._super(...arguments); + this._gmapCacheGPSToPlace = {}; + + // The google API will be loaded inside the website iframe. Let's try + // not having to load it in the backend too and just using the iframe + // google object instead. + this.contentWindow = this.$target[0].ownerDocument.defaultView; + + this.notification = this.bindService("notification"); + }, + /** + * @override + */ + async willStart() { + await this._super(...arguments); + this._gmapLoaded = await new Promise(resolve => { + this.trigger_up('gmap_api_request', { + editableMode: true, + configureIfNecessary: true, + onSuccess: key => { + if (!key) { + resolve(false); + return; + } + + // TODO see _notifyGMapError, this tries to trigger an error + // early but this is not consistent with new gmap keys. + this._nearbySearch('(50.854975,4.3753899)', !!key) + .then(place => resolve(!!place)); + }, + }); + }); + if (!this._gmapLoaded && !this._gmapErrorNotified) { + this.trigger_up('user_value_widget_critical'); + return; + } + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + this.el.classList.add('o_we_large'); + if (!this._gmapLoaded) { + return; + } + + this._gmapAutocomplete = new this.contentWindow.google.maps.places.Autocomplete(this.inputEl, {types: ['geocode']}); + this.contentWindow.google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this)); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + + // Without this, the google library injects elements inside the backend + // DOM but do not remove them once the editor is left. Notice that + // this is also done when the widget is destroyed for another reason + // than leaving the editor, but if the google API needs that container + // again afterwards, it will simply recreate it. + for (const el of document.body.querySelectorAll('.pac-container')) { + el.remove(); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + getMethodsParams: function (methodName) { + return Object.assign({gmapPlace: this._gmapPlace || {}}, this._super(...arguments)); + }, + /** + * @override + */ + async setValue() { + await this._super(...arguments); + if (!this._gmapLoaded) { + return; + } + + this._gmapPlace = await this._nearbySearch(this._value); + + if (this._gmapPlace) { + this.inputEl.value = this._gmapPlace.formatted_address; + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} gps + * @param {boolean} [notify=true] + * @returns {Promise} + */ + async _nearbySearch(gps, notify = true) { + if (this._gmapCacheGPSToPlace[gps]) { + return this._gmapCacheGPSToPlace[gps]; + } + + const p = gps.substring(1).slice(0, -1).split(','); + const location = new this.contentWindow.google.maps.LatLng(p[0] || 0, p[1] || 0); + return new Promise(resolve => { + const service = new this.contentWindow.google.maps.places.PlacesService(document.createElement('div')); + service.nearbySearch({ + // Do a 'nearbySearch' followed by 'getDetails' to avoid using + // GMap Geocoder which the user may not have enabled... but + // ideally Geocoder should be used to get the exact location at + // those coordinates and to limit billing query count. + location: location, + radius: 1, + }, (results, status) => { + const GMAP_CRITICAL_ERRORS = [ + this.contentWindow.google.maps.places.PlacesServiceStatus.REQUEST_DENIED, + this.contentWindow.google.maps.places.PlacesServiceStatus.UNKNOWN_ERROR + ]; + if (status === this.contentWindow.google.maps.places.PlacesServiceStatus.OK) { + service.getDetails({ + placeId: results[0].place_id, + fields: ['geometry', 'formatted_address'], + }, (place, status) => { + if (status === this.contentWindow.google.maps.places.PlacesServiceStatus.OK) { + this._gmapCacheGPSToPlace[gps] = place; + resolve(place); + } else if (GMAP_CRITICAL_ERRORS.includes(status)) { + if (notify) { + this._notifyGMapError(); + } + resolve(); + } + }); + } else if (GMAP_CRITICAL_ERRORS.includes(status)) { + if (notify) { + this._notifyGMapError(); + } + resolve(); + } else { + resolve(); + } + }); + }); + }, + /** + * Indicates to the user there is an error with the google map API and + * re-opens the configuration dialog. For good measures, this also notifies + * a critical error which normally removes the related snippet entirely. + * + * @private + */ + _notifyGMapError() { + // TODO this should be better to detect all errors. This is random. + // When misconfigured (wrong APIs enabled), sometimes Google throw + // errors immediately (which then reaches this code), sometimes it + // throws them later (which then induces an error log in the console + // and random behaviors). + if (this._gmapErrorNotified) { + return; + } + this._gmapErrorNotified = true; + + this.notification.add( + _t("A Google Map error occurred. Make sure to read the key configuration popup carefully."), + { type: 'danger', sticky: true } + ); + this.trigger_up('gmap_api_request', { + editableMode: true, + reconfigure: true, + onSuccess: () => { + this._gmapErrorNotified = false; + }, + }); + + setTimeout(() => this.trigger_up('user_value_widget_critical')); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onPlaceChanged(ev) { + const gmapPlace = this._gmapAutocomplete.getPlace(); + if (gmapPlace && gmapPlace.geometry) { + this._gmapPlace = gmapPlace; + const location = this._gmapPlace.geometry.location; + const oldValue = this._value; + this._value = `(${location.lat()},${location.lng()})`; + this._gmapCacheGPSToPlace[this._value] = gmapPlace; + if (oldValue !== this._value) { + this._onUserValueChange(ev); + } + } + }, +}); +options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget; +options.userValueWidgetsRegistry['we-fontfamilypicker'] = FontFamilyPickerUserValueWidget; +options.userValueWidgetsRegistry['we-gpspicker'] = GPSPicker; + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +options.Class.include({ + custom_events: Object.assign({}, options.Class.prototype.custom_events || {}, { + 'google_fonts_custo_request': '_onGoogleFontsCustoRequest', + }), + specialCheckAndReloadMethodsNames: ['customizeWebsiteViews', 'customizeWebsiteVariable', 'customizeWebsiteColor'], + + /** + * @override + */ + init() { + this._super(...arguments); + // Since the website is displayed in an iframe, its jQuery + // instance is not the same as the editor. This property allows + // for easy access to bootstrap plugins (Carousel, Modal, ...). + // This is only needed because jQuery doesn't send custom events + // the same way native javascript does. So if a jQuery instance + // triggers a custom event, only that same jQuery instance will + // trigger handlers set with `.on`. + this.$bsTarget = this.ownerDocument.defaultView.$(this.$target[0]); + + this.orm = this.bindService("orm"); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + customizeWebsiteViews: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'views'); + }, + /** + * @see this.selectClass for parameters + */ + customizeWebsiteVariable: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'variable'); + }, + /** + * @see this.selectClass for parameters + */ + customizeWebsiteVariables: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'variables'); + }, + /** + * @see this.selectClass for parameters + */ + customizeWebsiteColor: async function (previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'color'); + }, + /** + * @see this.selectClass for parameters + */ + async customizeWebsiteAssets(previewMode, widgetValue, params) { + await this._customizeWebsite(previewMode, widgetValue, params, 'assets'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _checkIfWidgetsUpdateNeedReload(widgets) { + const needReload = await this._super(...arguments); + if (needReload) { + return needReload; + } + for (const widget of widgets) { + const methodsNames = widget.getMethodsNames(); + const methodNamesToCheck = this.data.pageOptions + ? methodsNames + : methodsNames.filter(m => this.specialCheckAndReloadMethodsNames.includes(m)); + if (methodNamesToCheck.some(m => widget.getMethodsParams(m).reload)) { + return true; + } + } + return false; + }, + /** + * @override + */ + _computeWidgetState: async function (methodName, params) { + switch (methodName) { + case 'customizeWebsiteViews': { + return this._getEnabledCustomizeValues(params.possibleValues, true); + } + case 'customizeWebsiteVariable': { + const ownerDocument = this.$target[0].ownerDocument; + const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); + let finalValue = weUtils.getCSSVariableValue(params.variable, style); + if (!params.colorNames) { + return finalValue; + } + let tempValue = finalValue; + while (tempValue) { + finalValue = tempValue; + tempValue = weUtils.getCSSVariableValue(tempValue.replaceAll("'", ''), style); + } + return finalValue; + } + case 'customizeWebsiteColor': { + const ownerDocument = this.$target[0].ownerDocument; + const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); + return weUtils.getCSSVariableValue(params.color, style); + } + case 'customizeWebsiteAssets': { + return this._getEnabledCustomizeValues(params.possibleValues, false); + } + } + return this._super(...arguments); + }, + /** + * @private + */ + _customizeWebsite: async function (previewMode, widgetValue, params, type) { + // Never allow previews for theme customizations + if (previewMode) { + return; + } + + switch (type) { + case 'views': + await this._customizeWebsiteData(widgetValue, params, true); + break; + case 'variable': + await this._customizeWebsiteVariable(widgetValue, params); + break; + case "variables": + const defaultVariables = params.defaultVariables ? + Object.fromEntries(params.defaultVariables.split(",") + .map((variable) => variable.split(":").map(v => v.trim()))) : + {}; + const overriddenVariables = Object.fromEntries(widgetValue.split(",") + .map((variable) => variable.split(":").map(v => v.trim()))); + const variables = Object.assign(defaultVariables, overriddenVariables); + await this._customizeWebsiteVariables(variables, params.nullValue); + break; + case 'color': + await this._customizeWebsiteColor(widgetValue, params); + break; + case 'assets': + await this._customizeWebsiteData(widgetValue, params, false); + break; + default: + if (params.customCustomization) { + await params.customCustomization.call(this, widgetValue, params); + } + } + + if (params.reload || params.noBundleReload) { + // Caller will reload the page, nothing needs to be done anymore. + return; + } + await this._refreshBundles(); + }, + /** + * @private + */ + async _refreshBundles() { + // Finally, only update the bundles as no reload is required + await this._reloadBundles(); + + // Some public widgets may depend on the variables that were + // customized, so we have to restart them *all*. + await new Promise((resolve, reject) => { + this.trigger_up('widgets_start_request', { + editableMode: true, + onSuccess: () => resolve(), + onFailure: () => reject(), + }); + }); + }, + /** + * @private + */ + async _customizeWebsiteColor(color, params) { + await this._customizeWebsiteColors({[params.color]: color}, params); + }, + /** + * @private + */ + async _customizeWebsiteColors(colors, params) { + colors = colors || {}; + + const baseURL = '/website/static/src/scss/options/colors/'; + const colorType = params.colorType ? (params.colorType + '_') : ''; + const url = `${baseURL}user_${colorType}color_palette.scss`; + + const finalColors = {}; + for (const [colorName, color] of Object.entries(colors)) { + finalColors[colorName] = color; + if (color) { + if (weUtils.isColorCombinationName(color)) { + finalColors[colorName] = parseInt(color); + } else if (!isCSSColor(color)) { + finalColors[colorName] = `'${color}'`; + } + } + } + return this._makeSCSSCusto(url, finalColors, params.nullValue); + }, + /** + * @private + */ + _customizeWebsiteVariable: async function (value, params) { + return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', { + [params.variable]: value, + }, params.nullValue); + }, + /** + * Customizes several website variables at the same time. + * + * @private + * @param {Object} values: value per key variable + * @param {string} nullValue: string that represent null + */ + _customizeWebsiteVariables: async function (values, nullValue) { + await this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values, nullValue); + await this._refreshBundles(); + }, + /** + * @private + */ + async _customizeWebsiteData(value, params, isViewData) { + const allDataKeys = this._getDataKeysFromPossibleValues(params.possibleValues); + const keysToEnable = value.split(/\s*,\s*/); + const enableDataKeys = allDataKeys.filter(value => keysToEnable.includes(value)); + const disableDataKeys = allDataKeys.filter(value => !enableDataKeys.includes(value)); + const resetViewArch = !!params.resetViewArch; + + return rpc('/website/theme_customize_data', { + 'is_view_data': isViewData, + 'enable': enableDataKeys, + 'disable': disableDataKeys, + 'reset_view_arch': resetViewArch, + }); + }, + /** + * @private + */ + _getDataKeysFromPossibleValues(possibleValues) { + const allDataKeys = []; + for (const dataKeysStr of possibleValues) { + allDataKeys.push(...dataKeysStr.split(/\s*,\s*/)); + } + // return only unique non-empty strings + return allDataKeys.filter((v, i, arr) => v && arr.indexOf(v) === i); + }, + /** + * @private + * @param {Array} possibleValues + * @param {Boolean} isViewData true = "ir.ui.view", false = "ir.asset" + * @returns {String} + */ + async _getEnabledCustomizeValues(possibleValues, isViewData) { + const allDataKeys = this._getDataKeysFromPossibleValues(possibleValues); + const enabledValues = await rpc('/website/theme_customize_data_get', { + 'keys': allDataKeys, + 'is_view_data': isViewData, + }); + let mostValuesStr = ''; + let mostValuesNb = 0; + for (const valuesStr of possibleValues) { + const enableValues = valuesStr.split(/\s*,\s*/); + if (enableValues.length > mostValuesNb + && enableValues.every(value => enabledValues.includes(value))) { + mostValuesStr = valuesStr; + mostValuesNb = enableValues.length; + } + } + return mostValuesStr; // Need to return the exact same string as in possibleValues + }, + /** + * @private + */ + _makeSCSSCusto: async function (url, values, defaultValue = 'null') { + Object.keys(values).forEach((key) => { + values[key] = values[key] || defaultValue; + }); + return this.orm.call("web_editor.assets", "make_scss_customization", [url, values]); + }, + /** + * Refreshes all public widgets related to the given element. + * + * @private + * @param {jQuery} [$el=this.$target] + * @returns {Promise} + */ + _refreshPublicWidgets: async function ($el) { + return new Promise((resolve, reject) => { + this.trigger_up('widgets_start_request', { + editableMode: true, + $target: $el || this.$target, + onSuccess: resolve, + onFailure: reject, + }); + }); + }, + /** + * @private + */ + _reloadBundles: async function() { + return new Promise((resolve, reject) => { + this.trigger_up('reload_bundles', { + onSuccess: () => resolve(), + onFailure: () => reject(), + }); + }); + }, + /** + * @override + */ + _select: async function (previewMode, widget) { + await this._super(...arguments); + + // Some blocks flicker when we start their public widgets, so we skip + // the refresh for them to avoid the flickering. + const targetNoRefreshSelector = ".s_instagram_page"; + // TODO: we should review the way public widgets are restarted when + // converting to OWL and a new API. + if (this.options.isWebsite && !widget.$el.closest('[data-no-widget-refresh="true"]').length + && !this.$target[0].matches(targetNoRefreshSelector)) { + // TODO the flag should be retrieved through widget params somehow + await this._refreshPublicWidgets(); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {OdooEvent} ev + */ + _onGoogleFontsCustoRequest: function (ev) { + const values = ev.data.values ? Object.assign({}, ev.data.values) : {}; + const googleFonts = ev.data.googleFonts; + const googleLocalFonts = ev.data.googleLocalFonts; + if (googleFonts.length) { + values['google-fonts'] = "('" + googleFonts.join("', '") + "')"; + } else { + values['google-fonts'] = 'null'; + } + if (googleLocalFonts.length) { + values['google-local-fonts'] = "(" + googleLocalFonts.join(", ") + ")"; + } else { + values['google-local-fonts'] = 'null'; + } + this.trigger_up('snippet_edition_request', {exec: async () => { + return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values); + }}); + this.trigger_up('request_save', { + reloadEditor: true, + }); + }, +}); + +function _getLastPreFilterLayerElement($el) { + // Make sure parallax and video element are considered to be below the + // color filters / shape + const $bgVideo = $el.find('> .o_bg_video_container'); + if ($bgVideo.length) { + return $bgVideo[0]; + } + const $parallaxEl = $el.find('> .s_parallax_bg'); + if ($parallaxEl.length) { + return $parallaxEl[0]; + } + return null; +} + +options.registry.BackgroundToggler.include({ + /** + * Toggles background video on or off. + * + * @see this.selectClass for parameters + */ + toggleBgVideo(previewMode, widgetValue, params) { + if (!widgetValue) { + this.$target.find('> .o_we_bg_filter').remove(); + // TODO: use setWidgetValue instead of calling background directly when possible + const [bgVideoWidget] = this._requestUserValueWidgets('bg_video_opt'); + const bgVideoOpt = bgVideoWidget.getParent(); + return bgVideoOpt._setBgVideo(false, ''); + } else { + // TODO: use trigger instead of el.click when possible + this._requestUserValueWidgets('bg_video_opt')[0].el.click(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'toggleBgVideo') { + return this.$target[0].classList.contains('o_background_video'); + } + return this._super(...arguments); + }, + /** + * TODO an overall better management of background layers is needed + * + * @override + */ + _getLastPreFilterLayerElement() { + const el = _getLastPreFilterLayerElement(this.$target); + if (el) { + return el; + } + return this._super(...arguments); + }, +}); + +options.registry.BackgroundShape.include({ + /** + * TODO need a better management of background layers + * + * @override + */ + _getLastPreShapeLayerElement() { + const el = this._super(...arguments); + if (el) { + return el; + } + return _getLastPreFilterLayerElement(this.$target); + }, + /** + * @override + */ + _removeShapeEl(shapeEl) { + this.trigger_up('widgets_stop_request', { + $target: $(shapeEl), + }); + return this._super(...arguments); + }, +}); + +options.registry.ReplaceMedia.include({ + /** + * Adds an anchor to the url. + * Here "anchor" means a specific section of a page. + * + * @see this.selectClass for parameters + */ + setAnchor(previewMode, widgetValue, params) { + const linkEl = this.$target[0].parentElement; + let url = linkEl.getAttribute('href'); + url = url.split('#')[0]; + linkEl.setAttribute('href', url + widgetValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState(methodName, params) { + if (methodName === 'setAnchor') { + const parentEl = this.$target[0].parentElement; + if (parentEl.tagName === 'A') { + const href = parentEl.getAttribute('href') || ''; + return href ? `#${href.split('#')[1]}` : ''; + } + return ''; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'media_link_anchor_opt') { + const parentEl = this.$target[0].parentElement; + const linkEl = parentEl.tagName === 'A' ? parentEl : null; + const href = linkEl ? linkEl.getAttribute('href') : false; + return href && href.startsWith('/'); + } + return this._super(...arguments); + }, + /** + * Fills the dropdown with the available anchors for the page referenced in + * the href. + * + * @override + */ + async _renderCustomXML(uiFragment) { + if (!this.options.isWebsite) { + return this._super(...arguments); + } + await this._super(...arguments); + + + + const oldURLWidgetEl = uiFragment.querySelector('[data-name="media_url_opt"]'); + + const URLWidgetEl = document.createElement('we-urlpicker'); + // Copy attributes + for (const {name, value} of oldURLWidgetEl.attributes) { + URLWidgetEl.setAttribute(name, value); + } + URLWidgetEl.title = _t("Hint: Type '/' to search an existing page and '#' to link to an anchor."); + oldURLWidgetEl.replaceWith(URLWidgetEl); + + const hrefValue = this.$target[0].parentElement.getAttribute('href'); + if (!hrefValue || !hrefValue.startsWith('/')) { + return; + } + const urlWithoutAnchor = hrefValue.split('#')[0]; + const selectEl = document.createElement('we-select'); + selectEl.dataset.name = 'media_link_anchor_opt'; + selectEl.dataset.dependencies = 'media_url_opt'; + selectEl.dataset.noPreview = 'true'; + selectEl.classList.add('o_we_sublevel_1'); + selectEl.setAttribute('string', _t("Page Anchor")); + const anchors = await wUtils.loadAnchors(urlWithoutAnchor); + for (const anchor of anchors) { + const weButtonEl = document.createElement('we-button'); + weButtonEl.dataset.setAnchor = anchor; + weButtonEl.textContent = anchor; + selectEl.append(weButtonEl); + } + URLWidgetEl.after(selectEl); + }, +}); + +options.registry.BackgroundVideo = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Sets the target's background video. + * + * @see this.selectClass for parameters + */ + background: function (previewMode, widgetValue, params) { + if (previewMode === 'reset' && this.videoSrc) { + return this._setBgVideo(false, this.videoSrc); + } + return this._setBgVideo(previewMode, widgetValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName === 'background') { + if (this.$target[0].classList.contains('o_background_video')) { + return this.$('> .o_bg_video_container iframe').attr('src'); + } + return ''; + } + return this._super(...arguments); + }, + /** + * Updates the background video used by the snippet. + * + * @private + * @see this.selectClass for parameters + * @returns {Promise} + */ + _setBgVideo: async function (previewMode, value) { + this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true); + + if (previewMode !== false) { + return; + } + + this.videoSrc = value; + var target = this.$target[0]; + target.classList.toggle('o_background_video', !!(value && value.length)); + if (value && value.length) { + target.dataset.bgVideoSrc = value; + } else { + delete target.dataset.bgVideoSrc; + } + await this._refreshPublicWidgets(); + }, +}); + +options.registry.WebsiteLevelColor = options.Class.extend({ + specialCheckAndReloadMethodsNames: options.Class.prototype.specialCheckAndReloadMethodsNames + .concat(['customizeWebsiteLayer2Color']), + /** + * @constructor + */ + init() { + this._super(...arguments); + this._rpc = options.serviceCached(rpc); + }, + /** + * @see this.selectClass for parameters + */ + async customizeWebsiteLayer2Color(previewMode, widgetValue, params) { + if (previewMode) { + return; + } + params.color = params.layerColor; + params.variable = params.layerGradient; + let color = undefined; + let gradient = undefined; + if (weUtils.isColorGradient(widgetValue)) { + color = ''; + gradient = widgetValue; + } else { + color = widgetValue; + gradient = ''; + } + await this.customizeWebsiteVariable(previewMode, gradient, params); + params.noBundleReload = false; + return this.customizeWebsiteColor(previewMode, color, params); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'customizeWebsiteLayer2Color') { + params.variable = params.layerGradient; + const gradient = await this._computeWidgetState('customizeWebsiteVariable', params); + if (gradient) { + return gradient.substring(1, gradient.length - 1); // Unquote + } + params.color = params.layerColor; + return this._computeWidgetState('customizeWebsiteColor', params); + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + const _super = this._super.bind(this); + if ( + [ + "footer_language_selector_label_opt", + "footer_language_selector_opt", + ].includes(widgetName) + ) { + this._languages = await this._rpc.call("/website/get_languages"); + if (this._languages.length === 1) { + return false; + } + } + return _super(...arguments); + }, +}); + +options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ + GRAY_PARAMS: {EXTRA_SATURATION: "gray-extra-saturation", HUE: "gray-hue"}, + + /** + * @override + */ + init() { + this._super(...arguments); + this.grayParams = {}; + this.grays = {}; + this.orm = this.bindService("orm"); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUI() { + // The bg-XXX classes have been updated (and could be updated by another + // option like changing color palette) -> update the preview element. + const ownerDocument = this.$target[0].ownerDocument; + const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); + const grayPreviewEls = this.$el.find(".o_we_gray_preview span"); + for (const e of grayPreviewEls) { + const bgValue = weUtils.getCSSVariableValue(e.getAttribute('variable'), style); + e.style.setProperty("background-color", bgValue, "important"); + } + + // If the gray palette has been generated by Odoo standard option, + // the hue of all gray is the same and the saturation has been + // increased/decreased by the same amount for all grays in + // comparaison with BS grays. However the system supports any + // gray palette. + + const hues = []; + const saturationDiffs = []; + let oneHasNoSaturation = false; + const baseStyle = getComputedStyle(document.documentElement); + for (let id = 100; id <= 900; id += 100) { + const gray = weUtils.getCSSVariableValue(`${id}`, style); + const grayRGB = convertCSSColorToRgba(gray); + const grayHSL = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); + + const baseGray = weUtils.getCSSVariableValue(`base-${id}`, baseStyle); + const baseGrayRGB = convertCSSColorToRgba(baseGray); + const baseGrayHSL = convertRgbToHsl(baseGrayRGB.red, baseGrayRGB.green, baseGrayRGB.blue); + + if (grayHSL.saturation > 0.01) { + if (grayHSL.lightness > 0.01 && grayHSL.lightness < 99.99) { + hues.push(grayHSL.hue); + } + if (grayHSL.saturation < 99.99) { + saturationDiffs.push(grayHSL.saturation - baseGrayHSL.saturation); + } + } else { + oneHasNoSaturation = true; + } + } + this.grayHueIsDefined = !!hues.length; + + // Average of angles: we need to take the average of found hues + // because even if grays are supposed to be set to the exact + // same hue by the Odoo editor, there might be rounding errors + // during the conversion from RGB to HSL as the HSL system + // allows to represent more colors that the RGB hexadecimal + // notation (also: hue 360 = hue 0 and should not be averaged to 180). + // This also better support random gray palettes. + this.grayParams[this.GRAY_PARAMS.HUE] = (!hues.length) ? 0 : Math.round((Math.atan2( + hues.map(hue => Math.sin(hue * Math.PI / 180)).reduce((memo, value) => memo + value, 0) / hues.length, + hues.map(hue => Math.cos(hue * Math.PI / 180)).reduce((memo, value) => memo + value, 0) / hues.length + ) * 180 / Math.PI) + 360) % 360; + + // Average of found saturation diffs, or all grays have no + // saturation, or all grays are fully saturated. + this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION] = saturationDiffs.length + ? saturationDiffs.reduce((memo, value) => memo + value, 0) / saturationDiffs.length + : (oneHasNoSaturation ? -100 : 100); + + await this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async customizeGray(previewMode, widgetValue, params) { + // Gray parameters are used *on the JS side* to compute the grays that + // will be saved in the database. We indeed need those grays to be + // computed here for faster previews so this allows to not duplicate + // most of the logic. Also, this gives flexibility to maybe allow full + // customization of grays in custo and themes. Also, this allows to ease + // migration if the computation here was to change: the user grays would + // still be unchanged as saved in the database. + + this.grayParams[params.param] = parseInt(widgetValue); + for (let i = 1; i < 10; i++) { + const key = (100 * i).toString(); + this.grays[key] = this._buildGray(key); + } + + // Preview UI update + this.$el.find(".o_we_gray_preview").each((_, e) => { + e.style.setProperty("background-color", this.grays[e.getAttribute('variable')], "important"); + }); + + // Save all computed (JS side) grays in database + await this._customizeWebsite(previewMode, undefined, Object.assign({}, params, { + customCustomization: () => { // TODO this could be prettier + return this._customizeWebsiteColors(this.grays, Object.assign({}, params, { + colorType: 'gray', + })); + }, + })); + }, + /** + * @see this.selectClass for parameters + */ + async configureApiKey(previewMode, widgetValue, params) { + return new Promise(resolve => { + this.trigger_up('gmap_api_key_request', { + editableMode: true, + reconfigure: true, + onSuccess: () => resolve(), + }); + }); + }, + /** + * @see this.selectClass for parameters + */ + async customizeBodyBgType(previewMode, widgetValue, params) { + if (widgetValue === 'NONE') { + this.bodyImageType = 'image'; + return this.customizeBodyBg(previewMode, '', params); + } + // TODO improve: hack to click on external image picker + this.bodyImageType = widgetValue; + const widget = this._requestUserValueWidgets(params.imagepicker)[0]; + widget.enable(); + }, + /** + * @override + */ + async customizeBodyBg(previewMode, widgetValue, params) { + await this._customizeWebsiteVariables({ + 'body-image-type': this.bodyImageType, + 'body-image': widgetValue ? `'${widgetValue}'` : '', + }, params.nullValue); + }, + async openCustomCodeDialog(previewMode, widgetValue, params) { + return new Promise(resolve => { + this.trigger_up('open_edit_head_body_dialog', { + onSuccess: resolve, + }); + }); + }, + /** + * @see this.selectClass for parameters + */ + async switchTheme(previewMode, widgetValue, params) { + const save = await new Promise(resolve => { + this.dialog.add(ConfirmationDialog, { + body: _t("Changing theme requires to leave the editor. This will save all your changes, are you sure you want to proceed? Be careful that changing the theme will reset all your color customizations."), + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + if (!save) { + return; + } + this.trigger_up('request_save', { + reload: false, + action: 'website.theme_install_kanban_action', + }); + }, + /** + * @see this.selectClass for parameters + */ + async addLanguage(previewMode, widgetValue, params) { + // Retrieve the website id to check by default the website checkbox in + // the dialog box 'action_view_base_language_install' + const websiteId = this.options.context.website_id; + const save = await new Promise((resolve) => { + this.dialog.add(ConfirmationDialog, { + body: _t("Adding a language requires to leave the editor. This will save all your changes, are you sure you want to proceed?"), + confirm: () => resolve(true), + cancel: () => resolve(false), + }); + }); + if (!save) { + return; + } + this.trigger_up("request_save", { + reload: false, + action: "base.action_view_base_language_install", + options: { + additionalContext: { + params: { + website_id: websiteId, + url_return: "[lang]", + } + }, + } + }); + }, + /** + * @see this.selectClass for parameters + */ + async customizeButtonStyle(previewMode, widgetValue, params) { + await this._customizeWebsiteVariables({ + [`btn-${params.button}-outline`]: widgetValue === "outline" ? "true" : "false", + [`btn-${params.button}-flat`]: widgetValue === "flat" ? "true" : "false", + }, params.nullValue); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {String} id + * @returns {String} the adjusted color of gray + */ + _buildGray(id) { + // Getting base grays defined in color_palette.scss + const gray = weUtils.getCSSVariableValue(`base-${id}`, getComputedStyle(document.documentElement)); + const grayRGB = convertCSSColorToRgba(gray); + const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); + const adjustedGrayRGB = convertHslToRgb(this.grayParams[this.GRAY_PARAMS.HUE], + Math.min(Math.max(hsl.saturation + this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION], 0), 100), + hsl.lightness); + return convertRgbaToCSSColor(adjustedGrayRGB.red, adjustedGrayRGB.green, adjustedGrayRGB.blue); + }, + /** + * @override + */ + async _renderCustomXML(uiFragment) { + await this._super(...arguments); + const extraSaturationRangeEl = uiFragment.querySelector(`we-range[data-param=${this.GRAY_PARAMS.EXTRA_SATURATION}]`); + if (extraSaturationRangeEl) { + const baseGrays = range(100, 1000, 100).map(id => { + const gray = weUtils.getCSSVariableValue(`base-${id}`); + const grayRGB = convertCSSColorToRgba(gray); + const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); + return {id: id, hsl: hsl}; + }); + const first = baseGrays[0]; + const maxValue = baseGrays.reduce((gray, value) => { + return gray.hsl.saturation > value.hsl.saturation ? gray : value; + }, first); + const minValue = baseGrays.reduce((gray, value) => { + return gray.hsl.saturation < value.hsl.saturation ? gray : value; + }, first); + extraSaturationRangeEl.dataset.max = 100 - minValue.hsl.saturation; + extraSaturationRangeEl.dataset.min = -maxValue.hsl.saturation; + } + }, + /** + * @override + */ + async _checkIfWidgetsUpdateNeedWarning(widgets) { + const warningMessage = await this._super(...arguments); + if (warningMessage) { + return warningMessage; + } + for (const widget of widgets) { + if (widget.getMethodsNames().includes('customizeWebsiteVariable') + && widget.getMethodsParams('customizeWebsiteVariable').variable === 'color-palettes-name') { + const hasCustomizedColors = weUtils.getCSSVariableValue('has-customized-colors'); + if (hasCustomizedColors && hasCustomizedColors !== 'false') { + return _t("Changing the color palette will reset all your color customizations, are you sure you want to proceed?"); + } + } + } + return ''; + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'customizeBodyBgType') { + const bgImage = getComputedStyle(this.ownerDocument.querySelector('#wrapwrap'))['background-image']; + if (bgImage === 'none') { + return "NONE"; + } + return weUtils.getCSSVariableValue('body-image-type'); + } + if (methodName === 'customizeGray') { + // See updateUI override + return this.grayParams[params.param]; + } + if (methodName === 'customizeButtonStyle') { + const isOutline = weUtils.getCSSVariableValue(`btn-${params.button}-outline`); + const isFlat = weUtils.getCSSVariableValue(`btn-${params.button}-flat`); + return isFlat === "true" ? "flat" : isOutline === "true" ? "outline" : "fill"; + } + return this._super(...arguments); + }, + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'body_bg_image_opt') { + return false; + } + if (params.param === this.GRAY_PARAMS.HUE) { + return this.grayHueIsDefined; + } + if (params.removeFont) { + const font = await this._computeWidgetState('customizeWebsiteVariable', { + variable: params.removeFont, + }); + return !!font; + } + return this._super(...arguments); + }, +}); + +options.registry.ThemeColors = options.registry.OptionsTab.extend({ + /** + * @override + */ + async start() { + // Checks for support of the old color system + const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); + const supportOldColorSystem = weUtils.getCSSVariableValue('support-13-0-color-system', style) === 'true'; + const hasCustomizedOldColorSystem = weUtils.getCSSVariableValue('has-customized-13-0-color-system', style) === 'true'; + this._showOldColorSystemWarning = supportOldColorSystem && hasCustomizedOldColorSystem; + + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + async updateUIVisibility() { + await this._super(...arguments); + const oldColorSystemEl = this.el.querySelector('.o_old_color_system_warning'); + oldColorSystemEl.classList.toggle('d-none', !this._showOldColorSystemWarning); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _renderCustomXML(uiFragment) { + const paletteSelectorEl = uiFragment.querySelector('[data-variable="color-palettes-name"]'); + const style = window.getComputedStyle(document.documentElement); + const allPaletteNames = weUtils.getCSSVariableValue('palette-names', style).split(', ').map((name) => { + return name.replace(/'/g, ""); + }); + for (const paletteName of allPaletteNames) { + const btnEl = document.createElement('we-button'); + btnEl.classList.add('o_palette_color_preview_button'); + btnEl.dataset.customizeWebsiteVariable = `'${paletteName}'`; + [1, 3, 2].forEach(c => { + const colorPreviewEl = document.createElement('span'); + colorPreviewEl.classList.add('o_palette_color_preview'); + const color = weUtils.getCSSVariableValue(`o-palette-${paletteName}-o-color-${c}`, style); + colorPreviewEl.style.backgroundColor = color; + btnEl.appendChild(colorPreviewEl); + }); + paletteSelectorEl.appendChild(btnEl); + } + + const presetCollapseEl = uiFragment.querySelector('we-collapse.o_we_theme_presets_collapse'); + let ccPreviewEls = []; + for (let i = 1; i <= 5; i++) { + const collapseEl = document.createElement('we-collapse'); + const ccPreviewEl = $(renderToElement('web_editor.color.combination.preview.legacy'))[0]; + ccPreviewEl.classList.add('text-center', `o_cc${i}`, 'o_colored_level', 'o_we_collapse_toggler'); + collapseEl.appendChild(ccPreviewEl); + collapseEl.appendChild(renderToFragment('website.color_combination_edition', {number: i})); + ccPreviewEls.push(ccPreviewEl); + presetCollapseEl.appendChild(collapseEl); + } + await this._super(...arguments); + }, +}); + +options.registry.menu_data = options.Class.extend({ + init() { + this._super(...arguments); + this.orm = this.bindService("orm"); + this.notification = this.bindService("notification"); + }, + + /** + * When the users selects a menu, a popover is shown with 4 possible + * actions: follow the link in a new tab, copy the menu link, edit the menu, + * or edit the menu tree. + * The popover shows a preview of the menu link. Remote URL only show the + * favicon. + * + * @override + */ + start: function () { + const wysiwyg = $(this.ownerDocument.getElementById('wrapwrap')).data('wysiwyg'); + const popoverContainer = this.ownerDocument.getElementById('oe_manipulators'); + NavbarLinkPopoverWidget.createFor({ + target: this.$target[0], + wysiwyg, + container: popoverContainer, + notify: this.notification.add, + checkIsWebsiteDesigner: () => user.hasGroup("website.group_website_designer"), + onEditLinkClick: (widget) => { + var $menu = widget.$target.find('[data-oe-id]'); + this.trigger_up('menu_dialog', { + name: $menu.text(), + url: $menu.parent().attr('href'), + save: (name, url) => { + let websiteId; + this.trigger_up('context_get', { + callback: ctx => websiteId = ctx['website_id'], + }); + const data = { + id: $menu.data('oe-id'), + name, + url, + }; + return this.orm.call( + "website.menu", + "save", + [websiteId, {'data': [data]}] + ).then(function () { + widget.wysiwyg.odooEditor.observerUnactive(); + widget.$target.attr('href', url); + $menu.text(name); + widget.wysiwyg.odooEditor.observerActive(); + }); + }, + }); + widget.popover.hide(); + }, + onEditMenuClick: (widget) => { + const contentMenu = widget.target.closest('[data-content_menu_id]'); + const rootID = contentMenu ? parseInt(contentMenu.dataset.content_menu_id, 10) : undefined; + this.trigger_up('action_demand', { + actionName: 'edit_menu', + params: [rootID], + }); + }, + }); + return this._super(...arguments); + }, + /** + * When the users selects another element on the page, makes sure the + * popover is closed. + * + * @override + */ + onBlur: function () { + this.$target.popover('hide'); + }, +}); + +options.registry.Carousel = options.registry.CarouselHandler.extend({ + /** + * @override + */ + start: function () { + this.$bsTarget.carousel('pause'); + this.$indicators = this.$target.find('.carousel-indicators'); + this.$controls = this.$target.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + + // Prevent enabling the carousel overlay when clicking on the carousel + // controls (indeed we want it to change the carousel slide then enable + // the slide overlay) + See "CarouselItem" option. + this.$controls.addClass('o_we_no_overlay'); + + let _slideTimestamp; + this.$bsTarget.on('slide.bs.carousel.carousel_option', () => { + _slideTimestamp = window.performance.now(); + setTimeout(() => this.trigger_up('hide_overlay')); + }); + this.$bsTarget.on('slid.bs.carousel.carousel_option', () => { + // slid.bs.carousel is most of the time fired too soon by bootstrap + // since it emulates the transitionEnd with a setTimeout. We wait + // here an extra 20% of the time before retargeting edition, which + // should be enough... + const _slideDuration = (window.performance.now() - _slideTimestamp); + setTimeout(() => { + this.trigger_up('activate_snippet', { + $snippet: this.$target.find('.carousel-item.active'), + ifInactiveOptions: true, + }); + this.$bsTarget.trigger('active_slide_targeted'); + }, 0.2 * _slideDuration); + }); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$bsTarget.off('.carousel_option'); + }, + /** + * @override + */ + onBuilt: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + onClone: function () { + this._assignUniqueID(); + }, + /** + * @override + */ + cleanForSave: function () { + const $items = this.$target.find('.carousel-item'); + $items.removeClass('next prev left right active').first().addClass('active'); + this.$indicators.find('li').removeClass('active').empty().first().addClass('active'); + }, + /** + * @override + */ + notify: function (name, data) { + this._super(...arguments); + if (name === 'add_slide') { + this._addSlide(); + } + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + addSlide(previewMode, widgetValue, params) { + this._addSlide(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Creates a unique ID for the carousel and reassign data-attributes that + * depend on it. + * + * @private + */ + _assignUniqueID: function () { + const id = 'myCarousel' + Date.now(); + this.$target.attr('id', id); + this.$target.find('[data-bs-target]').attr('data-bs-target', '#' + id); + this.$target.find('[data-bs-slide], [data-bs-slide-to]').toArray().forEach((el) => { + var $el = $(el); + if ($el.attr('data-bs-target')) { + $el.attr('data-bs-target', '#' + id); + } else if ($el.attr('href')) { + $el.attr('href', '#' + id); + } + }); + }, + /** + * Adds a slide. + * + * @private + */ + _addSlide() { + const $items = this.$target.find('.carousel-item'); + this.$controls.removeClass('d-none'); + const $active = $items.filter('.active'); + this.$indicators.append($('
  • ', { + 'data-bs-target': '#' + this.$target.attr('id'), + 'data-bs-slide-to': $items.length, + })); + this.$indicators.append(' '); + // Need to remove editor data from the clone so it gets its own. + $active.clone(false) + .removeClass('active') + .insertAfter($active); + this.$bsTarget.carousel('next'); + }, + /** + * @override + */ + _getItemsGallery() { + return Array.from(this.$target[0].querySelectorAll(".carousel-item")); + }, + /** + * @override + */ + _reorderItems(itemsEls, newItemPosition) { + const carouselInnerEl = this.$target[0].querySelector(".carousel-inner"); + // First, empty the content of the carousel. + carouselInnerEl.replaceChildren(); + // Then fill it with the new slides. + for (const itemsEl of itemsEls) { + carouselInnerEl.append(itemsEl); + } + this._updateIndicatorAndActivateSnippet(newItemPosition); + }, + +}); + +options.registry.CarouselItem = options.Class.extend({ + isTopOption: true, + forceNoDeleteButton: true, + + /** + * @override + */ + start: function () { + this.$carousel = this.$bsTarget.closest('.carousel'); + this.$indicators = this.$carousel.find('.carousel-indicators'); + this.$controls = this.$carousel.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + + var leftPanelEl = this.$overlay.data('$optionsSection')[0]; + var titleTextEl = leftPanelEl.querySelector('we-title > span'); + this.counterEl = document.createElement('span'); + titleTextEl.appendChild(this.counterEl); + + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._super(...arguments); + this.$carousel.off('.carousel_item_option'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Updates the slide counter. + * + * @override + */ + updateUI: async function () { + await this._super(...arguments); + const $items = this.$carousel.find('.carousel-item'); + const $activeSlide = $items.filter('.active'); + const updatedText = ` (${$activeSlide.index() + 1}/${$items.length})`; + this.counterEl.textContent = updatedText; + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + addSlideItem(previewMode, widgetValue, params) { + this.trigger_up('option_update', { + optionName: 'Carousel', + name: 'add_slide', + }); + }, + /** + * Removes the current slide. + * + * @see this.selectClass for parameters. + */ + removeSlide: function (previewMode) { + const $items = this.$carousel.find('.carousel-item'); + const newLength = $items.length - 1; + if (!this.removing && newLength > 0) { + // The active indicator is deleted to ensure that the other + // indicators will still work after the deletion. + const $toDelete = $items.filter('.active').add(this.$indicators.find('.active')); + this.$carousel.one('active_slide_targeted.carousel_item_option', () => { + $toDelete.remove(); + // To ensure the proper functioning of the indicators, their + // attributes must reflect the position of the slides. + const indicatorsEls = this.$indicators[0].querySelectorAll('li'); + for (let i = 0; i < indicatorsEls.length; i++) { + indicatorsEls[i].setAttribute('data-bs-slide-to', i); + } + this.$controls.toggleClass('d-none', newLength === 1); + this.$carousel.trigger('content_changed'); + this.removing = false; + }); + this.removing = true; + this.$carousel.carousel('prev'); + } + }, + /** + * Goes to next slide or previous slide. + * + * @see this.selectClass for parameters + */ + switchToSlide: function (previewMode, widgetValue, params) { + switch (widgetValue) { + case 'left': + this.$controls.filter('.carousel-control-prev')[0].click(); + break; + case 'right': + this.$controls.filter('.carousel-control-next')[0].click(); + break; + } + }, +}); + +options.registry.Parallax = options.Class.extend({ + /** + * @override + */ + async start() { + this.parallaxEl = this.$target.find('> .s_parallax_bg')[0] || null; + this._updateBackgroundOptions(); + + this.$target.on('content_changed.ParallaxOption', this._onExternalUpdate.bind(this)); + + return this._super(...arguments); + }, + /** + * @override + */ + onFocus() { + // Refresh the parallax animation on focus; at least useful because + // there may have been changes in the page that influenced the parallax + // rendering (new snippets, ...). + // TODO make this automatic. + if (this.parallaxEl) { + this._refreshPublicWidgets(); + } + }, + /** + * @override + */ + onMove() { + this._refreshPublicWidgets(); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this.$target.off('.ParallaxOption'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Build/remove parallax. + * + * @see this.selectClass for parameters + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await this._super(...arguments); + if (params.attributeName !== 'scrollBackgroundRatio') { + return; + } + + const isParallax = (widgetValue !== '0'); + this.$target.toggleClass('parallax', isParallax); + this.$target.toggleClass('s_parallax_is_fixed', widgetValue === '1'); + this.$target.toggleClass('s_parallax_no_overflow_hidden', (widgetValue === '0' || widgetValue === '1')); + if (isParallax) { + if (!this.parallaxEl) { + this.parallaxEl = document.createElement('span'); + this.parallaxEl.classList.add('s_parallax_bg'); + this.$target.prepend(this.parallaxEl); + } + } else { + if (this.parallaxEl) { + this.parallaxEl.remove(); + this.parallaxEl = null; + } + } + + this._updateBackgroundOptions(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeVisibility(widgetName) { + return !this.$target.hasClass('o_background_video'); + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'selectDataAttribute' && params.parallaxTypeOpt) { + const attrName = params.attributeName; + const attrValue = (this.$target[0].dataset[attrName] || params.attributeDefaultValue).trim(); + switch (attrValue) { + case '0': + case '1': { + return attrValue; + } + default: { + return (attrValue.startsWith('-') ? '-1.5' : '1.5'); + } + } + } + return this._super(...arguments); + }, + /** + * Updates external background-related option to work with the parallax + * element instead of the original target when necessary. + * + * @private + */ + _updateBackgroundOptions() { + this.trigger_up('option_update', { + optionNames: ['BackgroundImage', 'BackgroundPosition', 'BackgroundOptimize'], + name: 'target', + data: this.parallaxEl ? $(this.parallaxEl) : this.$target, + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called on any snippet update to check if the parallax should still be + * enabled or not. + * + * TODO there is probably a better system to implement to solve this issue. + * + * @private + * @param {Event} ev + */ + _onExternalUpdate(ev) { + if (!this.parallaxEl) { + return; + } + const bgImage = this.parallaxEl.style.backgroundImage; + if (!bgImage || bgImage === 'none' || this.$target.hasClass('o_background_video')) { + // The parallax option was enabled but the background image was + // removed: disable the parallax option. + const widget = this._requestUserValueWidgets('parallax_none_opt')[0]; + widget.enable(); + widget.getParent().close(); // FIXME remove this ugly hack asap + } + }, +}); + +options.registry.collapse = options.Class.extend({ + /** + * @override + */ + start: function () { + var self = this; + this.$bsTarget.on('shown.bs.collapse hidden.bs.collapse', '[role="tabpanel"]', function () { + self.trigger_up('cover_update'); + self.$target.trigger('content_changed'); + }); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + onBuilt: function () { + this._createIDs(); + }, + /** + * @override + */ + onClone: function () { + this._createIDs(); + }, + /** + * @override + */ + onMove: function () { + this._createIDs(); + var $panel = this.$bsTarget.find('.collapse').removeData('bs.collapse'); + if ($panel.attr('aria-expanded') === 'true') { + $panel.closest('.accordion').find('.collapse[aria-expanded="true"]') + .filter((i, el) => (el !== $panel[0])) + .collapse('hide') + .one('hidden.bs.collapse', function () { + $panel.trigger('shown.bs.collapse'); + }); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Associates unique ids on collapse elements. + * + * @private + */ + _createIDs: function () { + let time = new Date().getTime(); + const $tablist = this.$target.closest('[role="tablist"]'); + const $tab = this.$target.find('[role="tab"]'); + const $panel = this.$target.find('[role="tabpanel"]'); + const $body = this.$target.closest('body'); + + const setUniqueId = ($elem, label) => { + let elemId = $elem.attr('id'); + if (!elemId || $body.find('[id="' + elemId + '"]').length > 1) { + do { + time++; + elemId = label + time; + } while ($body.find('#' + elemId).length); + $elem.attr('id', elemId); + } + return elemId; + }; + + const tablistId = setUniqueId($tablist, 'myCollapse'); + $panel.attr('data-bs-parent', '#' + tablistId); + $panel.data('bs-parent', '#' + tablistId); + + const panelId = setUniqueId($panel, 'myCollapseTab'); + $tab.attr('data-bs-target', '#' + panelId); + $tab.data('bs-target', '#' + panelId); + + $tab[0].setAttribute("aria-controls", panelId); + }, +}); + +options.registry.HeaderElements = options.Class.extend({ + /** + * @constructor + */ + init() { + this._super(...arguments); + this._rpc = options.serviceCached(rpc); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + const _super = this._super.bind(this); + switch (widgetName) { + case "header_language_selector_opt": + this._languages = await this._rpc.call("/website/get_languages"); + if (this._languages.length === 1) { + return false; + } + break; + } + return _super(...arguments); + }, +}); + +options.registry.HeaderNavbar = options.Class.extend({ + /** + * Particular case: we want the option to be associated on the header navbar + * in XML so that the related options only appear on navbar click (not + * header), in a different section, etc... but we still want the target to + * be the header itself. + * + * @constructor + */ + init() { + this._super(...arguments); + this.setTarget(this.$target.closest('#wrapwrap > header')); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Needs to be done manually for now because data-dependencies + * doesn't work with "AND" conditions. + * TODO: improve this. + * + * @override + */ + async _computeWidgetVisibility(widgetName, params) { + switch (widgetName) { + case 'option_logo_height_scrolled': { + return !!this.$('.navbar-brand').length; + } + } + return this._super(...arguments); + }, +}); + +const VisibilityPageOptionUpdate = options.Class.extend({ + pageOptionName: undefined, + showOptionWidgetName: undefined, + shownValue: '', + + /** + * @override + */ + async onTargetShow() { + if (await this._isShown()) { + // onTargetShow may be called even if the element is already shown. + // In most cases, this is not a problem but here it is as the code + // that follows clicks on the visibility checkbox regardless of its + // status. This avoids searching for that checkbox entirely. + return; + } + // TODO improve: here we make a hack so that if we make the invisible + // header appear for edition, its actual visibility for the page is + // toggled (otherwise it would be about editing an element which + // is actually never displayed on the page). + const widget = this._requestUserValueWidgets(this.showOptionWidgetName)[0]; + widget.enable(); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for params + */ + async visibility(previewMode, widgetValue, params) { + const show = (widgetValue !== 'hidden'); + await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: this.pageOptionName, value: show}], + onSuccess: () => resolve(), + onFailure: reject, + }); + }); + this.trigger_up('snippet_option_visibility_update', {show: show}); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'visibility') { + const shown = await this._isShown(); + return shown ? this.shownValue : 'hidden'; + } + return this._super(...arguments); + }, + /** + * @private + * @returns {boolean} + */ + async _isShown() { + return new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: [this.pageOptionName], + onSuccess: v => resolve(!!v), + onFailure: reject, + }); + }); + }, +}); + +options.registry.TopMenuVisibility = VisibilityPageOptionUpdate.extend({ + pageOptionName: 'header_visible', + showOptionWidgetName: 'regular_header_visibility_opt', + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles the switching between 3 differents visibilities of the header. + * + * @see this.selectClass for params + */ + async visibility(previewMode, widgetValue, params) { + await this._super(...arguments); + await this._changeVisibility(widgetValue); + // TODO this is hacky but changing the header visibility may have an + // effect on features like FullScreenHeight which depend on viewport + // size so we simulate a resize. + const targetWindow = this.$target[0].ownerDocument.defaultView; + targetWindow.dispatchEvent(new targetWindow.Event('resize')); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _changeVisibility(widgetValue) { + const show = (widgetValue !== 'hidden'); + if (!show) { + return; + } + const transparent = (widgetValue === 'transparent'); + await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_overlay', value: transparent}], + onSuccess: () => resolve(), + onFailure: reject, + }); + }); + if (!transparent) { + return; + } + // TODO should be able to change both options at the same time, as the + // `params` list suggests. + await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_color', value: ''}], + onSuccess: () => resolve(), + onFailure: reject, + }); + }); + await new Promise(resolve => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: 'header_text_color', value: ''}], + onSuccess: () => resolve(), + }); + }); + }, + /** + * @override + */ + async _computeWidgetState(methodName, params) { + const _super = this._super.bind(this); + if (methodName === 'visibility') { + this.shownValue = await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: v => resolve(v ? 'transparent' : 'regular'), + onFailure: reject, + }); + }); + } + return _super(...arguments); + }, +}); + +options.registry.topMenuColor = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + await this._super(...arguments); + if (widgetValue && !isCSSColor(widgetValue)) { + widgetValue = params.colorPrefix + widgetValue; + } + await new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'toggle_page_option', + params: [{name: params.pageOptionName, value: widgetValue}], + onSuccess: resolve, + onFailure: reject, + }); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeVisibility: async function () { + const show = await this._super(...arguments); + if (!show) { + return false; + } + return new Promise((resolve, reject) => { + this.trigger_up('action_demand', { + actionName: 'get_page_option', + params: ['header_overlay'], + onSuccess: value => resolve(!!value), + onFailure: reject, + }); + }); + }, +}); + +/** + * Manage the visibility of snippets on mobile/desktop. + */ +options.registry.DeviceVisibility = options.Class.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Toggles the device visibility. + * + * @see this.selectClass for parameters + */ + async toggleDeviceVisibility(previewMode, widgetValue, params) { + this.$target[0].classList.remove('d-none', 'd-md-none', 'd-lg-none', + 'o_snippet_mobile_invisible', 'o_snippet_desktop_invisible', + 'o_snippet_override_invisible', + ); + const style = getComputedStyle(this.$target[0]); + this.$target[0].classList.remove(`d-md-${style['display']}`, `d-lg-${style['display']}`); + if (widgetValue === 'no_desktop') { + this.$target[0].classList.add('d-lg-none', 'o_snippet_desktop_invisible'); + } else if (widgetValue === 'no_mobile') { + this.$target[0].classList.add(`d-lg-${style['display']}`, 'd-none', 'o_snippet_mobile_invisible'); + } + + // Update invisible elements. + const isMobile = wUtils.isMobile(this); + this.trigger_up('snippet_option_visibility_update', {show: widgetValue !== (isMobile ? 'no_mobile' : 'no_desktop')}); + }, + /** + * @override + */ + async onTargetHide() { + this.$target[0].classList.remove('o_snippet_override_invisible'); + }, + /** + * @override + */ + async onTargetShow() { + const isMobilePreview = weUtils.isMobileView(this.$target[0]); + const isMobileHidden = this.$target[0].classList.contains("o_snippet_mobile_invisible"); + if ((this.$target[0].classList.contains('o_snippet_mobile_invisible') + || this.$target[0].classList.contains('o_snippet_desktop_invisible') + ) && isMobilePreview === isMobileHidden) { + this.$target[0].classList.add('o_snippet_override_invisible'); + } + }, + /** + * @override + */ + cleanForSave() { + this.$target[0].classList.remove('o_snippet_override_invisible'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'toggleDeviceVisibility') { + const classList = [...this.$target[0].classList]; + if (classList.includes('d-none') && + classList.some(className => className.match(/^d-(md|lg)-/))) { + return 'no_mobile'; + } + if (classList.some(className => className.match(/d-(md|lg)-none/))) { + return 'no_desktop'; + } + return ''; + } + return await this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (this.$target[0].classList.contains('s_table_of_content_main')) { + return false; + } + return this._super(...arguments); + } +}); + +/** + * Hide/show footer in the current page. + */ +options.registry.HideFooter = VisibilityPageOptionUpdate.extend({ + pageOptionName: 'footer_visible', + showOptionWidgetName: 'hide_footer_page_opt', + shownValue: 'shown', +}); + +/** + * Handles the edition of snippet's anchor name. + */ +options.registry.anchor = options.Class.extend({ + isTopOption: true, + + /** + * @override + */ + init() { + this._super(...arguments); + this.notification = this.bindService("notification"); + }, + /** + * @override + */ + start() { + // Generate anchor and copy it to clipboard on click, show the tooltip on success + const buttonEl = this.el.querySelector("we-button"); + this.isModal = this.$target[0].classList.contains("modal"); + if (buttonEl && !this.isModal) { + this._buildClipboard(buttonEl); + } + + return this._super(...arguments); + }, + /** + * @override + */ + onClone: function () { + this.$target.removeAttr('data-anchor'); + this.$target.filter(':not(.carousel)').removeAttr('id'); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + notify(name, data) { + this._super(...arguments); + if (name === "modalAnchor") { + this._buildClipboard(data.buttonEl); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Element} buttonEl + */ + _buildClipboard(buttonEl) { + buttonEl.addEventListener("click", async (ev) => { + const anchorLink = this._getAnchorLink(); + await browser.navigator.clipboard.writeText(anchorLink); + const message = markup(_t("Anchor copied to clipboard
    Link: %s", anchorLink)); + this.notification.add(message, { + type: "success", + buttons: [{name: _t("Edit"), onClick: () => this._openAnchorDialog(buttonEl), primary: true}], + }); + }); + }, + + /** + * @private + * @param {Element} buttonEl + */ + _openAnchorDialog(buttonEl) { + const anchorDialog = class extends Component { + static template = "website.dialog.anchorName"; + static props = { close: Function, confirm: Function, delete: Function, currentAnchor: String }; + static components = { Dialog }; + title = _t("Link Anchor"); + modalRef = useChildRef(); + onClickConfirm() { + const shouldClose = this.props.confirm(this.modalRef); + if (shouldClose) { + this.props.close(); + } + } + onClickDelete() { + this.props.delete(); + this.props.close(); + } + onClickDiscard() { + this.props.close(); + } + }; + const props = { + confirm: (modalRef) => { + const inputEl = modalRef.el.querySelector(".o_input_anchor_name"); + const anchorName = this._text2Anchor(inputEl.value); + if (this.$target[0].id === anchorName) { + // If the chosen anchor name is already the one used by the + // element, close the dialog and do nothing else + return true; + } + + const alreadyExists = !!this.ownerDocument.getElementById(anchorName); + modalRef.el.querySelector('.o_anchor_already_exists').classList.toggle('d-none', !alreadyExists); + inputEl.classList.toggle('is-invalid', alreadyExists); + if (!alreadyExists) { + this._setAnchorName(anchorName); + buttonEl.click(); + return true; + } + }, + currentAnchor: decodeURIComponent(this.$target.attr('id')), + }; + if (this.$target.attr('id')) { + props["delete"] = () => { + this._setAnchorName(); + }; + } + this.dialog.add(anchorDialog, props); + }, + /** + * @private + * @param {String} value + */ + _setAnchorName: function (value) { + if (value) { + this.$target[0].id = value; + if (!this.isModal) { + this.$target[0].dataset.anchor = true; + } + } else { + this.$target.removeAttr('id data-anchor'); + } + this.$target.trigger('content_changed'); + }, + /** + * Returns anchor text. + * + * @private + * @returns {string} + */ + _getAnchorLink: function () { + if (!this.$target[0].id) { + const $titles = this.$target.find('h1, h2, h3, h4, h5, h6'); + const title = $titles.length > 0 ? $titles[0].innerText : this.data.snippetName; + const anchorName = this._text2Anchor(title); + let n = ''; + while (this.ownerDocument.getElementById(anchorName + n)) { + n = (n || 1) + 1; + } + this._setAnchorName(anchorName + n); + } + const pathName = this.isModal ? "" : this.ownerDocument.location.pathname; + return `${pathName}#${this.$target[0].id}`; + }, + /** + * Creates a safe id/anchor from text. + * + * @private + * @param {string} text + * @returns {string} + */ + _text2Anchor: function (text) { + return encodeURIComponent(text.trim().replace(/\s+/g, '-')); + }, +}); + +options.registry.HeaderBox = options.registry.Box.extend({ + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @override + */ + async selectStyle(previewMode, widgetValue, params) { + if ((params.variable || params.color) + && ['border-width', 'border-style', 'border-color', 'border-radius', 'box-shadow'].includes(params.cssProperty)) { + if (previewMode) { + return; + } + if (params.cssProperty === 'border-color') { + return this.customizeWebsiteColor(previewMode, widgetValue, params); + } + return this.customizeWebsiteVariable(previewMode, widgetValue, params); + } + return this._super(...arguments); + }, + /** + * @override + */ + async setShadow(previewMode, widgetValue, params) { + if (params.variable) { + if (previewMode) { + return; + } + const defaultShadow = this._getDefaultShadow(widgetValue, params.shadowClass); + return this.customizeWebsiteVariable(previewMode, defaultShadow, params); + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + const value = await this._super(...arguments); + if (methodName === "selectStyle" && params.cssProperty === "border-width") { + // One-sided borders return "0px 0px 3px 0px", which prevents the + // option from being displayed properly. We only keep the affected + // border. + return value.replace(/(^|\s)0px/gi, "").trim() || value; + } + return value; + }, +}); + +options.registry.CookiesBar = options.registry.SnippetPopup.extend({ + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Change the cookies bar layout. + * + * @see this.selectClass for parameters + */ + selectLayout: function (previewMode, widgetValue, params) { + let websiteId; + this.trigger_up('context_get', { + callback: function (ctx) { + websiteId = ctx['website_id']; + }, + }); + + const $template = $(renderToElement(`website.cookies_bar.${widgetValue}`, { + websiteId: websiteId, + })); + + const $content = this.$target.find('.modal-content'); + + // The order of selectors is significant since certain selectors may be + // nested within others, and we want to preserve the nested ones. + // For instance, in the case of '.o_cookies_bar_text_policy' nested + // inside '.o_cookies_bar_text_secondary', the parent selector should be + // copied first, followed by the child selector to ensure that the + // content of the nested selector is not overwritten. + const selectorsToKeep = [ + '.o_cookies_bar_text_button', + '.o_cookies_bar_text_button_essential', + '.o_cookies_bar_text_title', + '.o_cookies_bar_text_primary', + '.o_cookies_bar_text_secondary', + '.o_cookies_bar_text_policy' + ]; + + if (this.$savedSelectors === undefined) { + this.$savedSelectors = []; + } + + for (const selector of selectorsToKeep) { + const $currentLayoutEls = $content.find(selector).contents(); + const $newLayoutEl = $template.find(selector); + if ($currentLayoutEls.length) { + // save value before change, eg 'title' is not inside 'discrete' template + // but we want to preserve it in case of select another layout later + this.$savedSelectors[selector] = $currentLayoutEls; + } + const $savedSelector = this.$savedSelectors[selector]; + if ($newLayoutEl.length && $savedSelector && $savedSelector.length) { + $newLayoutEl.empty().append($savedSelector); + } + } + + $content.empty().append($template); + }, +}); + +/** + * Allows edition of 'cover_properties' in website models which have such + * fields (blogs, posts, events, ...). + */ +options.registry.CoverProperties = options.Class.extend({ + /** + * @constructor + */ + init: function () { + this._super.apply(this, arguments); + + this.$image = this.$target.find('.o_record_cover_image'); + this.$filter = this.$target.find('.o_record_cover_filter'); + }, + /** + * @override + */ + start: function () { + this.$filterValueOpts = this.$el.find('[data-filter-value]'); + + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Handles a background change. + * + * @see this.selectClass for parameters + */ + background: async function (previewMode, widgetValue, params) { + if (previewMode === false) { + this.$image[0].classList.remove("o_b64_image_to_save"); + } + if (widgetValue === '') { + this.$image.css('background-image', ''); + this.$target.removeClass('o_record_has_cover'); + } else { + if (previewMode === false) { + const imgEl = document.createElement("img"); + imgEl.src = widgetValue; + await loadImageInfo(imgEl); + if (imgEl.dataset.mimetype && ![ + "image/gif", + "image/svg+xml", + "image/webp", + ].includes(imgEl.dataset.mimetype)) { + // Convert to webp but keep original width. + imgEl.dataset.mimetype = "image/webp"; + const base64src = await applyModifications(imgEl, { + mimetype: "image/webp", + }); + widgetValue = base64src; + this.$image[0].classList.add("o_b64_image_to_save"); + } + } + this.$image.css('background-image', `url('${widgetValue}')`); + this.$target.addClass('o_record_has_cover'); + const $defaultSizeBtn = this.$el.find('.o_record_cover_opt_size_default'); + $defaultSizeBtn.click(); + $defaultSizeBtn.closest('we-select').click(); + } + + if (!previewMode) { + this._updateSavingDataset(); + } + }, + /** + * @see this.selectClass for parameters + */ + filterValue: function (previewMode, widgetValue, params) { + this.$filter.css('opacity', widgetValue || 0); + this.$filter.toggleClass('oe_black', parseFloat(widgetValue) !== 0); + + if (!previewMode) { + this._updateSavingDataset(); + } + }, + /** + * @override + */ + selectStyle: async function (previewMode, widgetValue, params) { + await this._super(...arguments); + + if (!previewMode) { + this._updateSavingDataset(widgetValue); + } + }, + /** + * @override + */ + selectClass: async function (previewMode, widgetValue, params) { + await this._super(...arguments); + + if (!previewMode) { + this._updateSavingDataset(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'filterValue': { + return parseFloat(this.$filter.css('opacity')).toFixed(1); + } + case 'background': { + const background = this.$image.css('background-image'); + if (background && background !== 'none') { + return background.match(/^url\(["']?(.+?)["']?\)$/)[1]; + } + return ''; + } + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility: function (widgetName, params) { + if (params.coverOptName) { + return this.$target.data(`use_${params.coverOptName}`) === 'True'; + } + return this._super(...arguments); + }, + /** + * @private + */ + _updateColorDataset(bgColorStyle = '', bgColorClass = '') { + this.$target[0].dataset.bgColorStyle = bgColorStyle; + this.$target[0].dataset.bgColorClass = bgColorClass; + }, + /** + * Updates the cover properties dataset used for saving. + * + * @private + */ + _updateSavingDataset(colorValue) { + const [colorPickerWidget, sizeWidget, textAlignWidget] = this._requestUserValueWidgets('bg_color_opt', 'size_opt', 'text_align_opt'); + // TODO: `o_record_has_cover` should be handled using model field, not + // resize_class to avoid all of this. + // Get values from DOM (selected values in options are only available + // after updateUI) + const sizeOptValues = sizeWidget.getMethodsParams('selectClass').possibleValues; + let coverClass = [...this.$target[0].classList].filter( + value => sizeOptValues.includes(value) + ).join(' '); + const bg = this.$image.css('background-image'); + if (bg && bg !== 'none') { + coverClass += " o_record_has_cover"; + } + const textAlignOptValues = textAlignWidget.getMethodsParams('selectClass').possibleValues; + const textAlignClass = [...this.$target[0].classList].filter( + value => textAlignOptValues.includes(value) + ).join(' '); + const filterEl = this.$target[0].querySelector('.o_record_cover_filter'); + const filterValue = filterEl && filterEl.style.opacity; + // Update saving dataset + this.$target[0].dataset.coverClass = coverClass; + this.$target[0].dataset.textAlignClass = textAlignClass; + this.$target[0].dataset.filterValue = filterValue || 0.0; + // TODO there is probably a better way and this should be refactored to + // use more standard colorpicker+imagepicker structure + const ccValue = colorPickerWidget._ccValue; + const colorOrGradient = colorPickerWidget._value; + const isGradient = weUtils.isColorGradient(colorOrGradient); + const valueIsCSSColor = !isGradient && isCSSColor(colorOrGradient); + const colorNames = []; + if (ccValue) { + colorNames.push(ccValue); + } + if (colorOrGradient && !isGradient && !valueIsCSSColor) { + colorNames.push(colorOrGradient); + } + const bgColorClass = weUtils.computeColorClasses(colorNames).join(' '); + const bgColorStyle = valueIsCSSColor ? `background-color: ${colorOrGradient};` : + isGradient ? `background-color: rgba(0, 0, 0, 0); background-image: ${colorOrGradient};` : ''; + this._updateColorDataset(bgColorStyle, bgColorClass); + }, +}); + +options.registry.ScrollButton = options.Class.extend({ + /** + * @override + */ + start: async function () { + await this._super(...arguments); + this.$button = this.$('.o_scroll_button'); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * @see this.selectClass for parameters + */ + async showScrollButton(previewMode, widgetValue, params) { + if (widgetValue) { + this.$button.show(); + } else { + if (previewMode) { + this.$button.hide(); + } else { + this.$button.detach(); + } + } + }, + /** + * Toggles the scroll down button. + */ + toggleButton: function (previewMode, widgetValue, params) { + if (widgetValue) { + if (!this.$button.length) { + const anchor = document.createElement('a'); + anchor.classList.add( + 'o_scroll_button', + 'mb-3', + 'rounded-circle', + 'align-items-center', + 'justify-content-center', + 'mx-auto', + 'bg-primary', + 'o_not_editable', + ); + anchor.href = '#'; + anchor.contentEditable = "false"; + anchor.title = _t("Scroll down to next section"); + const arrow = document.createElement('i'); + arrow.classList.add('fa', 'fa-angle-down', 'fa-3x'); + anchor.appendChild(arrow); + this.$button = $(anchor); + } + this.$target.append(this.$button); + } else { + this.$button.detach(); + } + }, + /** + * @override + */ + async selectClass(previewMode, widgetValue, params) { + await this._super(...arguments); + // If a "d-lg-block" class exists on the section (e.g., for mobile + // visibility option), it should be replaced with a "d-lg-flex" class. + // This ensures that the section has the "display: flex" property + // applied, which is the default rule for both "height" option classes. + if (params.possibleValues.includes("o_half_screen_height")) { + if (widgetValue) { + this.$target[0].classList.replace("d-lg-block", "d-lg-flex"); + } else if (this.$target[0].classList.contains("d-lg-flex")) { + // There are no known cases, but we still make sure that the + //
    element doesn't have a "display: flex" originally. + this.$target[0].classList.remove("d-lg-flex"); + const sectionStyle = window.getComputedStyle(this.$target[0]); + const hasDisplayFlex = sectionStyle.getPropertyValue("display") === "flex"; + this.$target[0].classList.add(hasDisplayFlex ? "d-lg-flex" : "d-lg-block"); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _renderCustomXML(uiFragment) { + // TODO We should have a better way to change labels depending on some + // condition (maybe a dedicated way in updateUI...) + if (this.$target[0].dataset.snippet === 's_image_gallery') { + const minHeightEl = uiFragment.querySelector('[data-name="minheight_auto_opt"]'); + minHeightEl.parentElement.setAttribute('string', _t("Min-Height")); + } + }, + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + switch (methodName) { + case 'toggleButton': + return !!this.$button.parent().length; + } + return this._super(...arguments); + }, + /** + * @override + */ + _computeWidgetVisibility(widgetName, params) { + if (widgetName === 'fixed_height_opt') { + return (this.$target[0].dataset.snippet === 's_image_gallery'); + } + return this._super(...arguments); + }, +}); + +options.registry.ConditionalVisibility = options.registry.DeviceVisibility.extend({ + /** + * @constructor + */ + init() { + this._super(...arguments); + this.optionsAttributes = []; + }, + /** + * @override + */ + async start() { + await this._super(...arguments); + + for (const widget of this._userValueWidgets) { + const params = widget.getMethodsParams(); + if (params.saveAttribute) { + this.optionsAttributes.push({ + saveAttribute: params.saveAttribute, + attributeName: params.attributeName, + // If callWith dataAttribute is not specified, the default + // field to check on the record will be .value for values + // coming from another widget than M2M. + callWith: params.callWith || 'value', + }); + } + } + }, + /** + * @override + */ + async onTargetHide() { + await this._super(...arguments); + if (this.$target[0].classList.contains('o_snippet_invisible')) { + this.$target[0].classList.add('o_conditional_hidden'); + } + }, + /** + * @override + */ + async onTargetShow() { + await this._super(...arguments); + this.$target[0].classList.remove('o_conditional_hidden'); + }, + // Todo: remove me in master. + /** + * @override + */ + cleanForSave() {}, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Inserts or deletes record's id and value in target's data-attributes + * if no ids are selected, deletes the attribute. + * + * @see this.selectClass for parameters + */ + selectRecord(previewMode, widgetValue, params) { + const recordsData = JSON.parse(widgetValue); + if (recordsData.length) { + this.$target[0].dataset[params.saveAttribute] = widgetValue; + } else { + delete this.$target[0].dataset[params.saveAttribute]; + } + + this._updateCSSSelectors(); + }, + /** + * Selects a value for target's data-attributes. + * Should be used instead of selectRecord if the visibility is not related + * to database values. + * + * @see this.selectClass for parameters + */ + selectValue(previewMode, widgetValue, params) { + if (widgetValue) { + const widgetValueIndex = params.possibleValues.indexOf(widgetValue); + const value = [{value: widgetValue, id: widgetValueIndex}]; + this.$target[0].dataset[params.saveAttribute] = JSON.stringify(value); + } else { + delete this.$target[0].dataset[params.saveAttribute]; + } + + this._updateCSSSelectors(); + }, + /** + * Opens the toggler when 'conditional' is selected. + * + * @override + */ + async selectDataAttribute(previewMode, widgetValue, params) { + await this._super(...arguments); + + if (params.attributeName === 'visibility') { + const targetEl = this.$target[0]; + if (widgetValue === 'conditional') { + const collapseEl = this.$el.children('we-collapse')[0]; + this._toggleCollapseEl(collapseEl); + } else { + // TODO create a param to allow doing this automatically for genericSelectDataAttribute? + delete targetEl.dataset.visibility; + + for (const attribute of this.optionsAttributes) { + delete targetEl.dataset[attribute.saveAttribute]; + delete targetEl.dataset[`${attribute.saveAttribute}Rule`]; + } + } + this.trigger_up('snippet_option_visibility_update', {show: true}); + } else if (!params.isVisibilityCondition) { + return; + } + + this._updateCSSSelectors(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _computeWidgetState(methodName, params) { + if (methodName === 'selectRecord') { + return this.$target[0].dataset[params.saveAttribute] || '[]'; + } + if (methodName === 'selectValue') { + const selectedValue = this.$target[0].dataset[params.saveAttribute]; + return selectedValue ? JSON.parse(selectedValue)[0].value : params.attributeDefaultValue; + } + return this._super(...arguments); + }, + /** + * Reads target's attributes and creates CSS selectors. + * Stores them in data-attributes to then be reapplied by + * content/inject_dom.js (ideally we should saved them in a