+
+
@@ -259,4 +260,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..4497b05c036cd 100644
--- a/addons/web_editor/views/snippets.xml
+++ b/addons/web_editor/views/snippets.xml
@@ -390,10 +390,6 @@
-
-
@@ -456,307 +452,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reset
-
-
-
- Reset
-
-
-
-
-
-
- Default
- 25%
- 50%
- 100%
-
-
-
-
-
- None
- Left
- Center
- Right
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py
index 2a90cf3065884..e7ed2dfcdb961 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',
@@ -241,31 +240,57 @@
'website/static/src/scss/website.edit_mode.scss',
'website/static/src/js/editor/snippets.editor.js',
'website/static/src/js/editor/snippets.options.js',
+ 'website/static/src/js/editor/snippets.options.xml',
+ 'website/static/src/js/editor/snippets.options.legacy.js',
+ 'website/static/src/js/editor/snippets.registry.js',
'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/snippets/s_badge/options.xml',
+ 'website/static/src/snippets/s_hr/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 +298,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..84224cbfcbfa4 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,10 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu {
* @override
*/
setup() {
+ useSubEnv({
+ gmapApiRequest: this._onGMapAPIRequest.bind(this),
+ gmapApiKeyRequest: this._onGMapAPIKeyRequest.bind(this),
+ });
super.setup();
this.notification = useService("notification");
this.dialog = useService("dialog");
@@ -224,6 +228,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.
diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js
index 64d67b76e3fd9..e1d9ece37109d 100644
--- a/addons/website/static/src/js/editor/snippets.options.js
+++ b/addons/website/static/src/js/editor/snippets.options.js
@@ -6,15 +6,17 @@ 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 {
applyModifications,
isImageCorsProtected,
isImageSupportedForStyle,
+ loadImage,
loadImageInfo,
} from "@web_editor/js/editor/image_processing";
import "@website/snippets/s_popup/options";
@@ -35,11 +37,36 @@ 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'];
+import {
+ BackgroundToggler,
+ Box,
+ ButtonUserValue,
+ CarouselHandler,
+ ImageTools,
+ LayoutColumn,
+ Many2oneUserValue,
+ registerBackgroundOptions,
+ registerOption,
+ SelectTemplate,
+ SelectUserValue,
+ serviceCached,
+ SnippetMove,
+ SnippetOption,
+ SnippetOptionComponent,
+ SnippetSave,
+ UserValue,
+ UserValueComponent,
+ vAlignment,
+ WeButton,
+ WeInput,
+ WeSelect,
+ WeTitle,
+} from '@web_editor/js/editor/snippets.options';
+import { registerWebsiteOption } from "./snippets.registry";
options.UserValueWidget.include({
loadMethodsData() {
@@ -61,12 +88,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 +118,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 +161,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 +203,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 +228,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 +238,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 +257,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.components, WeButton, WeTitle };
+ 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 +319,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 +471,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 +478,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 +487,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 +505,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 +534,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 +545,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 +570,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 +590,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 +620,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({data: {
editableMode: true,
configureIfNecessary: true,
onSuccess: key => {
@@ -687,44 +653,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);
+ });
},
- });
+ }, stopPropagation: () => {}});
});
- 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 +671,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 +748,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 +767,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 +779,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 +824,34 @@ 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',
- }),
+patch(SnippetOption.prototype, {
specialCheckAndReloadMethodsNames: ['customizeWebsiteViews', 'customizeWebsiteVariable', 'customizeWebsiteColor'],
/**
* @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 +860,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 +905,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 +923,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 +951,7 @@ options.Class.include({
return this._getEnabledCustomizeValues(params.possibleValues, false);
}
}
- return this._super(...arguments);
+ return super._computeWidgetState(...arguments);
},
/**
* @private
@@ -1152,7 +1121,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 +1132,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,
@@ -1185,15 +1154,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 +1175,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,15 +1195,20 @@ 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,
});
},
});
+options.Class.include({
+ // TODO Keep until WebsiteLevelColor is converted
+ specialCheckAndReloadMethodsNames: ['customizeWebsiteViews', 'customizeWebsiteVariable', 'customizeWebsiteColor'],
+});
+
function _getLastPreFilterLayerElement($el) {
// Make sure parallax and video element are considered to be below the
// color filters / shape
@@ -1251,7 +1223,7 @@ function _getLastPreFilterLayerElement($el) {
return null;
}
-options.registry.BackgroundToggler.include({
+patch(BackgroundToggler.prototype, {
/**
* Toggles background video on or off.
*
@@ -1262,11 +1234,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 +1253,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 +1265,7 @@ options.registry.BackgroundToggler.include({
if (el) {
return el;
}
- return this._super(...arguments);
+ return super._getLastPreFilterLayerElement(...arguments);
},
});
@@ -1411,7 +1383,7 @@ options.registry.ReplaceMedia.include({
},
});
-options.registry.BackgroundVideo = options.Class.extend({
+class BackgroundVideo extends SnippetOption {
//--------------------------------------------------------------------------
// Options
@@ -1422,12 +1394,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 +1408,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 +1416,7 @@ options.registry.BackgroundVideo = options.Class.extend({
return '';
}
return this._super(...arguments);
- },
+ }
/**
* Updates the background video used by the snippet.
*
@@ -1452,7 +1424,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,8 +1440,8 @@ options.registry.BackgroundVideo = options.Class.extend({
delete target.dataset.bgVideoSrc;
}
await this._refreshPublicWidgets();
- },
-});
+ }
+}
options.registry.WebsiteLevelColor = options.Class.extend({
specialCheckAndReloadMethodsNames: options.Class.prototype.specialCheckAndReloadMethodsNames
@@ -2012,11 +1984,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 +2003,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 +2012,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 +2064,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({
*/
addSlide(previewMode, widgetValue, params) {
this._addSlide();
- },
+ }
//--------------------------------------------------------------------------
// Private
@@ -2106,7 +2076,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 +2088,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({
$el.attr('href', '#' + id);
}
});
- },
+ }
/**
* Adds a slide.
*
@@ -2138,13 +2108,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 +2127,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 +2167,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 +2184,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 +2216,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 +2231,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 +2266,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 +2291,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 +2314,7 @@ options.registry.Parallax = options.Class.extend({
}
this._updateBackgroundOptions();
- },
+ }
//--------------------------------------------------------------------------
// Private
@@ -2345,7 +2325,7 @@ options.registry.Parallax = options.Class.extend({
*/
async _computeVisibility(widgetName) {
return !this.$target.hasClass('o_background_video');
- },
+ }
/**
* @override
*/
@@ -2363,8 +2343,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 +2352,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,8 +2384,8 @@ options.registry.Parallax = options.Class.extend({
widget.enable();
widget.getParent().close(); // FIXME remove this ugly hack asap
}
- },
-});
+ }
+}
options.registry.collapse = options.Class.extend({
/**
@@ -2760,8 +2740,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 +2766,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 +2785,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 +2812,8 @@ options.registry.DeviceVisibility = options.Class.extend({
}
return '';
}
- return await this._super(...arguments);
- },
+ return await super._computeWidgetState(...arguments);
+ }
/**
* @override
*/
@@ -2841,8 +2821,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",
});
/**
@@ -3326,14 +3312,11 @@ options.registry.CoverProperties = options.Class.extend({
},
});
-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 +3335,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 +3365,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 +3387,7 @@ options.registry.ScrollButton = options.Class.extend({
this.$target[0].classList.add(hasDisplayFlex ? "d-lg-flex" : "d-lg-block");
}
}
- },
+ }
//--------------------------------------------------------------------------
// Private
@@ -3420,17 +3403,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 +3421,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 +3511,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 +3529,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 +3549,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 +3572,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,15 +3648,21 @@ 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({
- /**
- * @override
- */
- async start() {
- await this._super(...arguments);
+/**
+ * Mixin to be extended as is by WebsiteAnimate and with additional methods for
+ * ImageToolsAnimate.
+ */
+const WebsiteAnimateMixin = (T) => class extends T {
+ constructor() {
+ super(...arguments);
// Animations for which the "On Scroll" and "Direction" options are not
// available.
this.limitedAnimations = ['o_anim_flash', 'o_anim_pulse', 'o_anim_shake', 'o_anim_tada', 'o_anim_flip_in_x', 'o_anim_flip_in_y'];
@@ -3667,34 +3670,13 @@ options.registry.WebsiteAnimate = options.Class.extend({
this.$optionsSection = this.$overlay.data('$optionsSection');
this.$scrollingElement = $().getScrollingElement(this.ownerDocument);
this.$overlay[0].querySelector(".o_handles").classList.toggle("pe-none", this.isAnimatedText);
- },
+ }
/**
* @override
*/
async onBuilt() {
this.$target[0].classList.toggle('o_animate_preview', this.$target[0].classList.contains('o_animate'));
- },
- /**
- * @override
- */
- onFocus() {
- if (this.isAnimatedText) {
- // For animated text, the animation options must be in the editor
- // toolbar.
- this.options.wysiwyg.toolbarEl.append(this.$el[0]);
- this.$optionsSection.addClass('d-none');
- }
- },
- /**
- * @override
- */
- onBlur() {
- if (this.isAnimatedText) {
- // For animated text, the options must be returned to their
- // original location as they were moved in the toolbar.
- this.$optionsSection.append(this.$el);
- }
- },
+ }
/**
* @override
*/
@@ -3704,7 +3686,7 @@ options.registry.WebsiteAnimate = options.Class.extend({
// remove the lazy loading on them.
this._toggleImagesLazyLoading(false);
}
- },
+ }
//--------------------------------------------------------------------------
// Options
@@ -3714,23 +3696,23 @@ options.registry.WebsiteAnimate = options.Class.extend({
* @override
*/
async selectClass(previewMode, widgetValue, params) {
- await this._super(...arguments);
+ await super.selectClass(...arguments);
if (params.forceAnimation && params.name !== 'o_anim_no_effect_opt' && previewMode !== 'reset') {
this._forceAnimation();
}
if (params.isAnimationTypeSelection) {
this.$target[0].classList.toggle("o_animate_preview", this.$target[0].classList.contains("o_animate"));
}
- },
+ }
/**
* @override
*/
async selectDataAttribute(previewMode, widgetValue, params) {
- await this._super(...arguments);
+ await super.selectDataAttribute(...arguments);
if (params.forceAnimation) {
this._forceAnimation();
}
- },
+ }
/**
* Sets the animation mode.
*
@@ -3744,6 +3726,7 @@ options.registry.WebsiteAnimate = options.Class.extend({
this.$target[0].style.animationPlayState = '';
this.$target[0].style.animationName = '';
this.$target[0].style.visibility = '';
+
if (widgetValue === 'onScroll') {
this.$target[0].dataset.scrollZoneStart = 0;
this.$target[0].dataset.scrollZoneEnd = 100;
@@ -3751,20 +3734,12 @@ options.registry.WebsiteAnimate = options.Class.extend({
delete this.$target[0].dataset.scrollZoneStart;
delete this.$target[0].dataset.scrollZoneEnd;
}
- if (params.activeValue === "o_animate_on_hover") {
- this.trigger_up("option_update", {
- optionName: "ImageTools",
- name: "disable_hover_effect",
- });
- }
- if ((!params.activeValue || params.activeValue === "o_animate_on_hover")
- && widgetValue && widgetValue !== "onHover") {
- // If "Animation" was on "None" or "o_animate_on_hover" and it is no
- // longer, it is set to "fade_in" by default.
+
+ const setToFadeIn = () => {
targetClassList.add('o_anim_fade_in');
this._toggleImagesLazyLoading(false);
}
- if (!widgetValue || widgetValue === "onHover") {
+ const resetProperties = () => {
const possibleEffects = this._requestUserValueWidgets('animation_effect_opt')[0].getMethodsParams('selectClass').possibleValues;
const possibleDirections = this._requestUserValueWidgets('animation_direction_opt')[0].getMethodsParams('selectClass').possibleValues;
const possibleEffectsAndDirections = possibleEffects.concat(possibleDirections);
@@ -3779,17 +3754,20 @@ options.registry.WebsiteAnimate = options.Class.extend({
this.$target[0].style.animationDuration = '';
this._toggleImagesLazyLoading(true);
}
- if (widgetValue === "onHover") {
- // Pause the history until the hover effect is applied in
- // "setImgShapeHoverEffect". This prevents saving the intermediate
- // steps done (in a tricky way) up to that point.
- this.options.wysiwyg.odooEditor.historyPauseSteps();
- this.trigger_up("option_update", {
- optionName: "ImageTools",
- name: "enable_hover_effect",
- });
+
+ if (!params.ImageToolsAnimate) {
+ if (!params.activeValue && widgetValue) {
+ // If "Animation" was on "None" and it is no longer, it is set
+ // to "fade_in" by default.
+ setToFadeIn();
+ }
+ if (!widgetValue) {
+ resetProperties();
+ }
}
- },
+
+ return { setToFadeIn, resetProperties };
+ }
/**
* Sets the animation intensity.
*
@@ -3798,7 +3776,7 @@ options.registry.WebsiteAnimate = options.Class.extend({
animationIntensity(previewMode, widgetValue, params) {
this.$target[0].style.setProperty('--wanim-intensity', widgetValue);
this._forceAnimation();
- },
+ }
//--------------------------------------------------------------------------
// Private
@@ -3820,9 +3798,7 @@ options.registry.WebsiteAnimate = options.Class.extend({
// being launched twice when previewing the "Intensity" option).
await new Promise(resolve => setTimeout(resolve));
this.$target.addClass('o_animating');
- this.trigger_up('cover_update', {
- overlayVisible: true,
- });
+ this.callbacks.coverUpdate(true);
this.$scrollingElement[0].classList.add('o_wanim_overflow_xy_hidden');
this.$target.css('animation-name', '');
this.$target.one('webkitAnimationEnd oanimationend msAnimationEnd animationend', () => {
@@ -3830,7 +3806,7 @@ options.registry.WebsiteAnimate = options.Class.extend({
this.$target.removeClass('o_animating');
});
}
- },
+ }
/**
* @override
*/
@@ -3872,20 +3848,11 @@ options.registry.WebsiteAnimate = options.Class.extend({
return true;
}
case 'animation_on_hover_opt': {
- const [hoverEffectOverlayWidget] = this._requestUserValueWidgets("hover_effect_overlay_opt");
- if (hoverEffectOverlayWidget) {
- const hoverEffectWidget = hoverEffectOverlayWidget.getParent();
- const imageToolsOpt = hoverEffectWidget.getParent();
- return (
- imageToolsOpt._canHaveHoverEffect()
- && !await isImageCorsProtected(this.$target[0])
- );
- }
return false;
}
}
- return this._super(...arguments);
- },
+ return super._computeWidgetVisibility(...arguments);
+ }
/**
* @override
*/
@@ -3893,8 +3860,8 @@ options.registry.WebsiteAnimate = options.Class.extend({
if (this.$target[0].matches('img')) {
return isImageSupportedForStyle(this.$target[0]);
}
- return this._super(...arguments);
- },
+ return super._computeVisibility(...arguments);
+ }
/**
* @override
*/
@@ -3902,8 +3869,8 @@ options.registry.WebsiteAnimate = options.Class.extend({
if (methodName === 'animationIntensity') {
return window.getComputedStyle(this.$target[0]).getPropertyValue('--wanim-intensity');
}
- return this._super(...arguments);
- },
+ return super._computeWidgetState(...arguments);
+ }
/**
* Removes or adds the lazy loading on images because animated images can
* appear before or after their parents and cause bugs in the animations.
@@ -3925,7 +3892,465 @@ options.registry.WebsiteAnimate = options.Class.extend({
imgEl.loading = 'eager';
}
}
- },
+ }
+}
+const WebsiteAnimate = WebsiteAnimateMixin(SnippetOption);
+
+registerWebsiteOption("WebsiteAnimate", {
+ Class: WebsiteAnimate,
+ template: "website.WebsiteAnimate",
+ selector: ".o_animable, section .row > div, .fa, .btn",
+ exclude: "[data-oe-xpath], .o_not-animable, .s_col_no_resize.row > div, .s_col_no_resize",
+});
+registerWebsiteOption("TextAnimate", {
+ Class: WebsiteAnimate,
+ template: "website.WebsiteAnimate",
+ selector: ".o_animated_text",
+ textSelector: ".o_animated_text",
+});
+
+export class ImageToolsAnimate extends WebsiteAnimateMixin(ImageTools) {
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ animationMode(previewMode, widgetValue, params) {
+ params.ImageToolsAnimate = true;
+ const { setToFadeIn, resetProperties } = super.animationMode(...arguments);
+ if (params.activeValue === "o_animate_on_hover") {
+ this._disableHoverEffect();
+ }
+ if ((!params.activeValue || params.activeValue === "o_animate_on_hover")
+ && widgetValue && widgetValue !== "onHover") {
+ // If "Animation" was on "None" or "o_animate_on_hover" and it is no
+ // longer, it is set to "fade_in" by default.
+ setToFadeIn();
+ }
+ if (!widgetValue || widgetValue === "onHover") {
+ resetProperties();
+ }
+ if (widgetValue === "onHover") {
+ // Pause the history until the hover effect is applied in
+ // "setImgShapeHoverEffect". This prevents saving the intermediate
+ // steps done (in a tricky way) up to that point.
+ this.options.wysiwyg.odooEditor.historyPauseSteps();
+ this._enableHoverEffect();
+ }
+ }
+ /**
+ * 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, 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 super.selectDataAttribute(...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();
+ }
+ /**
+ * @see this.selectClass for parameters
+ */
+ showHoverEffect(previewMode, widgetValue, params) {}
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async updateUI() {
+ await super.updateUI(...arguments);
+ // Adapts the colorpicker label according to the selected "On Hover"
+ // animation.
+ const hoverEffectName = this.$target[0].dataset.hoverEffect;
+ if (hoverEffectName) {
+ const needToAdaptLabel = ["image_zoom_in", "image_zoom_out", "dolly_zoom"].includes(hoverEffectName);
+ const newContext = await this._getRenderContext();
+ newContext.hoverEffectColorLabel = needToAdaptLabel ? _t("Overlay") : _t("Color");
+ Object.assign(this.renderContext, newContext);
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ async _computeWidgetVisibility(widgetName, params) {
+ switch (widgetName) {
+ case "animation_on_hover_opt": {
+ return this._canHaveHoverEffect() && !await isImageCorsProtected(this.$target[0]);
+ }
+ case "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();
+ }
+ return super._computeWidgetVisibility(...arguments);
+ }
+ /**
+ * @override
+ */
+ _computeWidgetState(methodName, params) {
+ if (methodName === "setHoverEffectColor") {
+ const imgEl = this._getImg();
+ return imgEl.dataset.hoverEffectColor || "";
+ }
+ return super._computeWidgetState(...arguments);
+ }
+ /**
+ * @override
+ */
+ 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;
+ }
+ /**
+ * @override
+ */
+ async _computeImgShapeHoverEffect(svgEl, imgEl) {
+ // Add shape animations on hover.
+ if (imgEl.dataset.hoverEffect && this._canHaveHoverEffect()) {
+ this._addImageShapeHoverEffect(svgEl, imgEl);
+ }
+ }
+ /**
+ * 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) {
+ const parser = new DOMParser();
+ const response = await fetch("/website/static/src/svg/hover_effects.svg");
+ const xmlDoc = parser.parseFromString(await response.text(), "text/xml");
+ this.hoverEffectsSvg = xmlDoc.documentElement;
+ }
+ 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;
+ }
+ }
+ }
+ /**
+ * 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();
+ }
+ }
+ /**
+ * Enables the hover effect on the image.
+ *
+ * @private
+ */
+ _enableHoverEffect() {
+ this.env.snippetEditionRequest(() => {
+ // 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();
+ }
+ // Add the "Overlay" hover effect to the shape.
+ this.firstHoverEffect = true;
+ const hoverEffectOverlayWidget = this._requestUserValueWidgets("hover_effect_overlay_opt")[0];
+ hoverEffectOverlayWidget.enable();
+ });
+ }
+ /**
+ * @override
+ */
+ async _select(previewMode, widget) {
+ await super._select(...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"
+ const hasSetImgShapeHoverEffectMethod = widget.getMethodsNames().includes("setImgShapeHoverEffect");
+ const hasShowHoverEffectMethod = widget.getMethodsNames().includes("showHoverEffect");
+ // 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 && hasShowHoverEffectMethod) {
+ 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);
+ }
+ /**
+ * @override
+ */
+ _resetImgShape(imgEl) {
+ super._resetImgShape(...arguments);
+ if (!this._canHaveHoverEffect()) {
+ delete imgEl.dataset.hoverEffect;
+ delete imgEl.dataset.hoverEffectColor;
+ delete imgEl.dataset.hoverEffectStrokeWidth;
+ delete imgEl.dataset.hoverEffectIntensity;
+ imgEl.classList.remove("o_animate_on_hover");
+ }
+ if (!this._isAnimatedShape()) {
+ delete imgEl.dataset.shapeAnimationSpeed;
+ }
+ }
+ /**
+ * @override
+ */
+ _removeImgShapeWithHoverEffectHook(imgEl, widgetValue) {
+ if (imgEl.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");
+ }
+ return super._removeImgShapeWithHoverEffectHook(imgEl, widgetValue);
+ }
+ /**
+ * @override
+ */
+ _deleteHoverAttributes(imgEl) {
+ delete imgEl.dataset.hoverEffect;
+ delete imgEl.dataset.hoverEffectColor;
+ delete imgEl.dataset.hoverEffectStrokeWidth;
+ delete imgEl.dataset.hoverEffectIntensity;
+ img.classList.remove("o_animate_on_hover");
+ }
+}
+registerWebsiteOption("ImageToolsAnimate", {
+ Class: ImageToolsAnimate,
+ template: "website.ImageToolsAnimate",
+ selector: "img",
+ exclude: "[data-oe-type='image'] > img, [data-oe-xpath]",
});
/**
@@ -3944,34 +4369,16 @@ options.registry.WebsiteAnimate = options.Class.extend({
* `
* To correctly adapt each highlight unit when the text content is changed.
*/
-options.registry.TextHighlight = options.Class.extend({
- custom_events: Object.assign({}, options.Class.prototype.custom_events, {
- "user_value_widget_opening": "_onWidgetOpening",
- }),
+class TextHighlight extends SnippetOption {
/**
* @override
*/
- async start() {
- await this._super(...arguments);
- this.leftPanelEl = this.$overlay.data("$optionsSection")[0];
+ constructor() {
+ super(...arguments);
// Reduce overlay opacity for more highlight visibility on small text.
this.$overlay[0].style.opacity = "0.25";
this.$overlay[0].querySelector(".o_handles").classList.add("pe-none");
- },
- /**
- * Move "Text Effect" options to the editor's toolbar.
- *
- * @override
- */
- onFocus() {
- this.options.wysiwyg.toolbarEl.append(this.$el[0]);
- },
- /**
- * @override
- */
- onBlur() {
- this.leftPanelEl.appendChild(this.el);
- },
+ }
/**
* @override
*/
@@ -3982,8 +4389,8 @@ options.registry.TextHighlight = options.Class.extend({
this._autoAdaptHighlights();
this._requestUserValueWidgets("text_highlight_opt")[0]?.enable();
}
- this._super(...arguments);
- },
+ super.notify(...arguments);
+ }
//--------------------------------------------------------------------------
// Options
@@ -3997,7 +4404,7 @@ options.registry.TextHighlight = options.Class.extend({
async setTextHighlight(previewMode, widgetValue, params) {
return widgetValue ? this._addTextHighlight(widgetValue)
: removeTextHighlight(this.$target[0]);
- },
+ }
//--------------------------------------------------------------------------
// Private
@@ -4025,7 +4432,7 @@ options.registry.TextHighlight = options.Class.extend({
} else {
this._autoAdaptHighlights();
}
- },
+ }
/**
* Used to set the highlight effect DOM structure on the targeted text
* content.
@@ -4033,50 +4440,95 @@ options.registry.TextHighlight = options.Class.extend({
* @private
*/
_autoAdaptHighlights() {
- this.trigger_up("snippet_edition_request", { exec: async () =>
+ this.env.snippetEditionRequest(async () =>
await this._refreshPublicWidgets($(this.options.wysiwyg.odooEditor.editable))
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
+ );
+ }
+}
+registerWebsiteOption("TextHighlight", {
+ Class: TextHighlight,
+ template: "website.TextHighlight",
+ selector: ".o_text_highlight",
+ textSelector: ".o_text_highlight",
+});
+class TextHighlightBtnUserValue extends ButtonUserValue {
+ constructor() {
+ super(...arguments);
+ this._state.textContentRef = undefined;
+ }
/**
- * To draw highlight SVGs for `` preview, we need to open the
- * widget (we need correct size values from `getBoundingClientRect()`).
- * This code will build the highlight preview the first time we open the
- * ``.
- *
- * @private
+ * @type {import("@web/core/utils/hooks").Ref}
*/
- _onWidgetOpening(ev) {
- const target = ev.target;
+ set textContentRef(value) {
+ this._state.textContentRef = value;
+ }
+ /**
+ * Mounts the SVG on the .
+ * This has to be done here in the UserValue because it depends on the
+ * parent WeTextHighlightSelect opening - which has access to its subValues
+ * but not to its children components.
+ */
+ mountSvg() {
// Only when there is no highlight SVGs.
- if (target.getName() === "text_highlight_opt" && !target.el.querySelector("svg")) {
- const weToggler = target.el.querySelector("we-toggler");
- weToggler.classList.add("active");
- [...target.el.querySelectorAll("we-button[data-set-text-highlight] div")].forEach(weBtnEl => {
- weBtnEl.textContent = "Text";
- // Get the text highlight linked to each ``
- // and apply it to its text content.
- weBtnEl.append(drawTextHighlightSVG(weBtnEl, weBtnEl.parentElement.dataset.setTextHighlight));
- });
+ if (
+ this._state.textContentRef.el
+ && this._state.textContentRef.el.querySelector("div")
+ && !this._state.textContentRef.el.querySelector("svg")
+ ) {
+ // Get the text highlight linked to the button and apply it to its
+ // text content.
+ const el = this._state.textContentRef.el.querySelector("div");
+ el.append(drawTextHighlightSVG(el, this._data.setTextHighlight));
}
- },
-});
+ }
+}
+class WeTextHighlightBtn extends WeButton {
+ static template = "website.WeTextHighlightBtn";
+ static StateModel = TextHighlightBtnUserValue;
+ setup() {
+ super.setup();
+ this.state.textContentRef = this.textContentRef;
+ }
+}
+registry.category("snippet_widgets").add("WeTextHighlightBtn", WeTextHighlightBtn);
+
+class WeTextHighlightSelect extends WeSelect {
+ setup() {
+ super.setup();
+ useEffect(
+ (opened) => {
+ // To draw highlight SVGs for `` previews, we need
+ // the component to be opened (we need the correct size values
+ // from `getBoundingClientRect()`). This code will build the
+ // highlight preview the first time we open the ``.
+ if (opened) {
+ for (const userValue of Object.values(this.state._subValues)) {
+ if (userValue instanceof TextHighlightBtnUserValue) {
+ userValue.mountSvg();
+ }
+ }
+ }
+ },
+ () => [this.state.opened]
+ );
+ }
+}
+registry.category("snippet_widgets").add("WeTextHighlightSelect", WeTextHighlightSelect);
/**
* 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 +4545,9 @@ options.registry.MegaMenuLayout = options.registry.SelectTemplate.extend({
data.onSuccess();
});
} else {
- this._super(...arguments);
+ super.notify(...arguments);
}
- },
+ }
//--------------------------------------------------------------------------
// Private
@@ -4104,28 +4556,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 +4600,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({
@@ -4295,8 +4764,8 @@ options.registry.GridImage = options.Class.extend({
},
});
-options.registry.GalleryElement = options.Class.extend({
+class GalleryElement extends SnippetOption {
//--------------------------------------------------------------------------
// Options
//--------------------------------------------------------------------------
@@ -4310,7 +4779,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,8 +4787,13 @@ 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({
/**
@@ -4420,25 +4894,132 @@ options.registry.Button = options.Class.extend({
},
});
-options.registry.layout_column.include({
+class WebsiteLayoutColumn extends LayoutColumn {
/**
* @override
*/
_isMobile() {
- return wUtils.isMobile(this);
- },
+ return this.env.services.website.context.isMobile;
+ }
+}
+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",
});
-options.registry.SnippetMove.include({
+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",
+});
+
+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..ac99720e5aedd
--- /dev/null
+++ b/addons/website/static/src/js/editor/snippets.options.legacy.js
@@ -0,0 +1,4223 @@
+/** @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