diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/website/static/src/js/content | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website/static/src/js/content')
| -rw-r--r-- | addons/website/static/src/js/content/compatibility.js | 38 | ||||
| -rw-r--r-- | addons/website/static/src/js/content/menu.js | 642 | ||||
| -rw-r--r-- | addons/website/static/src/js/content/ripple_effect.js | 72 | ||||
| -rw-r--r-- | addons/website/static/src/js/content/snippets.animation.js | 1092 | ||||
| -rw-r--r-- | addons/website/static/src/js/content/website_root.js | 350 | ||||
| -rw-r--r-- | addons/website/static/src/js/content/website_root_instance.js | 26 |
6 files changed, 2220 insertions, 0 deletions
diff --git a/addons/website/static/src/js/content/compatibility.js b/addons/website/static/src/js/content/compatibility.js new file mode 100644 index 00000000..f4148802 --- /dev/null +++ b/addons/website/static/src/js/content/compatibility.js @@ -0,0 +1,38 @@ +odoo.define('website.content.compatibility', function (require) { +'use strict'; + +/** + * Tweaks the website rendering so that the old browsers correctly render the + * content too. + */ + +require('web.dom_ready'); + +// Check the browser and its version and add the info as an attribute of the +// HTML element so that css selectors can match it +var browser = _.findKey($.browser, function (v) { return v === true; }); +if ($.browser.mozilla && +$.browser.version.replace(/^([0-9]+\.[0-9]+).*/, '\$1') < 20) { + browser = 'msie'; +} +browser += (',' + $.browser.version); +var mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i; +if (mobileRegex.test(window.navigator.userAgent.toLowerCase())) { + browser += ',mobile'; +} +document.documentElement.setAttribute('data-browser', browser); + +// Check if flex is supported and add the info as an attribute of the HTML +// element so that css selectors can match it (only if not supported) +var htmlStyle = document.documentElement.style; +var isFlexSupported = (('flexWrap' in htmlStyle) + || ('WebkitFlexWrap' in htmlStyle) + || ('msFlexWrap' in htmlStyle)); +if (!isFlexSupported) { + document.documentElement.setAttribute('data-no-flex', ''); +} + +return { + browser: browser, + isFlexSupported: isFlexSupported, +}; +}); diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js new file mode 100644 index 00000000..71f74ab5 --- /dev/null +++ b/addons/website/static/src/js/content/menu.js @@ -0,0 +1,642 @@ +odoo.define('website.content.menu', function (require) { +'use strict'; + +const config = require('web.config'); +var dom = require('web.dom'); +var publicWidget = require('web.public.widget'); +var wUtils = require('website.utils'); +var animations = require('website.content.snippets.animation'); + +const extraMenuUpdateCallbacks = []; + +const BaseAnimatedHeader = animations.Animation.extend({ + disabledInEditableMode: false, + effects: [{ + startEvents: 'scroll', + update: '_updateHeaderOnScroll', + }, { + startEvents: 'resize', + update: '_updateHeaderOnResize', + }], + + /** + * @constructor + */ + init: function () { + this._super(...arguments); + this.fixedHeader = false; + this.scrolledPoint = 0; + this.hasScrolled = false; + }, + /** + * @override + */ + start: function () { + this.$main = this.$el.next('main'); + this.isOverlayHeader = !!this.$el.closest('.o_header_overlay, .o_header_overlay_theme').length; + this.$dropdowns = this.$el.find('.dropdown, .dropdown-menu'); + this.$navbarCollapses = this.$el.find('.navbar-collapse'); + + // While scrolling through navbar menus on medium devices, body should not be scrolled with it + this.$navbarCollapses.on('show.bs.collapse.BaseAnimatedHeader', function () { + if (config.device.size_class <= config.device.SIZES.SM) { + $(document.body).addClass('overflow-hidden'); + } + }).on('hide.bs.collapse.BaseAnimatedHeader', function () { + $(document.body).removeClass('overflow-hidden'); + }); + + // We can rely on transitionend which is well supported but not on + // transitionstart, so we listen to a custom odoo event. + this._transitionCount = 0; + this.$el.on('odoo-transitionstart.BaseAnimatedHeader', () => this._adaptToHeaderChangeLoop(1)); + this.$el.on('transitionend.BaseAnimatedHeader', () => this._adaptToHeaderChangeLoop(-1)); + + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._toggleFixedHeader(false); + this.$el.removeClass('o_header_affixed o_header_is_scrolled o_header_no_transition'); + this.$navbarCollapses.off('.BaseAnimatedHeader'); + this.$el.off('.BaseAnimatedHeader'); + this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _adaptFixedHeaderPosition() { + dom.compensateScrollbar(this.el, this.fixedHeader, false, 'right'); + }, + /** + * @private + */ + _adaptToHeaderChange: function () { + this._updateMainPaddingTop(); + this.el.classList.toggle('o_top_fixed_element', this.fixedHeader && this._isShown()); + + for (const callback of extraMenuUpdateCallbacks) { + callback(); + } + }, + /** + * @private + * @param {integer} [addCount=0] + */ + _adaptToHeaderChangeLoop: function (addCount = 0) { + this._adaptToHeaderChange(); + + this._transitionCount += addCount; + this._transitionCount = Math.max(0, this._transitionCount); + + // As long as we detected a transition start without its related + // transition end, keep updating the main padding top. + if (this._transitionCount > 0) { + window.requestAnimationFrame(() => this._adaptToHeaderChangeLoop()); + + // The normal case would be to have the transitionend event to be + // fired but we cannot rely on it, so we use a timeout as fallback. + if (addCount !== 0) { + clearTimeout(this._changeLoopTimer); + this._changeLoopTimer = setTimeout(() => { + this._adaptToHeaderChangeLoop(-this._transitionCount); + }, 500); + } + } else { + // When we detected all transitionend events, we need to stop the + // setTimeout fallback. + clearTimeout(this._changeLoopTimer); + } + }, + /** + * @private + */ + _computeTopGap() { + return 0; + }, + /** + * @private + */ + _isShown() { + return true; + }, + /** + * @private + * @param {boolean} [useFixed=true] + */ + _toggleFixedHeader: function (useFixed = true) { + this.fixedHeader = useFixed; + this._adaptToHeaderChange(); + this.el.classList.toggle('o_header_affixed', useFixed); + this._adaptFixedHeaderPosition(); + }, + /** + * @private + */ + _updateMainPaddingTop: function () { + this.headerHeight = this.$el.outerHeight(); + this.topGap = this._computeTopGap(); + + if (this.isOverlayHeader) { + return; + } + this.$main.css('padding-top', this.fixedHeader ? this.headerHeight : ''); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the window is scrolled + * + * @private + * @param {integer} scroll + */ + _updateHeaderOnScroll: function (scroll) { + // Disable css transition if refresh with scrollTop > 0 + if (!this.hasScrolled) { + this.hasScrolled = true; + if (scroll > 0) { + this.$el.addClass('o_header_no_transition'); + } + } else { + this.$el.removeClass('o_header_no_transition'); + } + + // Indicates the page is scrolled, the logo size is changed. + const headerIsScrolled = (scroll > this.scrolledPoint); + if (this.headerIsScrolled !== headerIsScrolled) { + this.el.classList.toggle('o_header_is_scrolled', headerIsScrolled); + this.$el.trigger('odoo-transitionstart'); + this.headerIsScrolled = headerIsScrolled; + } + + // Close opened menus + this.$dropdowns.removeClass('show'); + this.$navbarCollapses.removeClass('show').attr('aria-expanded', false); + }, + /** + * Called when the window is resized + * + * @private + */ + _updateHeaderOnResize: function () { + this._adaptFixedHeaderPosition(); + if (document.body.classList.contains('overflow-hidden') + && config.device.size_class > config.device.SIZES.SM) { + document.body.classList.remove('overflow-hidden'); + this.$el.find('.navbar-collapse').removeClass('show'); + } + }, +}); + +publicWidget.registry.StandardAffixedHeader = BaseAnimatedHeader.extend({ + selector: 'header.o_header_standard:not(.o_header_sidebar)', + + /** + * @constructor + */ + init: function () { + this._super(...arguments); + this.fixedHeaderShow = false; + this.scrolledPoint = 300; + }, + /** + * @override + */ + start: function () { + this.headerHeight = this.$el.outerHeight(); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _isShown() { + return !this.fixedHeader || this.fixedHeaderShow; + }, + /** + * Called when the window is scrolled + * + * @private + * @param {integer} scroll + */ + _updateHeaderOnScroll: function (scroll) { + this._super(...arguments); + + const mainPosScrolled = (scroll > this.headerHeight + this.topGap); + const reachPosScrolled = (scroll > this.scrolledPoint + this.topGap); + + // Switch between static/fixed position of the header + if (this.fixedHeader !== mainPosScrolled) { + this.$el.css('transform', mainPosScrolled ? 'translate(0, -100%)' : ''); + void this.$el[0].offsetWidth; // Force a paint refresh + this._toggleFixedHeader(mainPosScrolled); + } + // Show/hide header + if (this.fixedHeaderShow !== reachPosScrolled) { + this.$el.css('transform', reachPosScrolled ? `translate(0, -${this.topGap}px)` : 'translate(0, -100%)'); + this.fixedHeaderShow = reachPosScrolled; + this._adaptToHeaderChange(); + } + }, +}); + +publicWidget.registry.FixedHeader = BaseAnimatedHeader.extend({ + selector: 'header.o_header_fixed', + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _updateHeaderOnScroll: function (scroll) { + this._super(...arguments); + // Need to be 'unfixed' when the window is not scrolled so that the + // transparent menu option still works. + if (scroll > (this.scrolledPoint + this.topGap)) { + if (!this.$el.hasClass('o_header_affixed')) { + this.$el.css('transform', `translate(0, -${this.topGap}px)`); + void this.$el[0].offsetWidth; // Force a paint refresh + this._toggleFixedHeader(true); + } + } else { + this._toggleFixedHeader(false); + void this.$el[0].offsetWidth; // Force a paint refresh + this.$el.css('transform', ''); + } + }, +}); + +const BaseDisappearingHeader = publicWidget.registry.FixedHeader.extend({ + /** + * @override + */ + init: function () { + this._super(...arguments); + this.scrollingDownwards = true; + this.hiddenHeader = false; + this.position = 0; + this.atTop = true; + this.checkPoint = 0; + this.scrollOffsetLimit = 200; + }, + /** + * @override + */ + destroy: function () { + this._showHeader(); + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _hideHeader: function () { + this.$el.trigger('odoo-transitionstart'); + }, + /** + * @override + */ + _isShown() { + return !this.fixedHeader || !this.hiddenHeader; + }, + /** + * @private + */ + _showHeader: function () { + this.$el.trigger('odoo-transitionstart'); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _updateHeaderOnScroll: function (scroll) { + this._super(...arguments); + + const scrollingDownwards = (scroll > this.position); + const atTop = (scroll <= 0); + if (scrollingDownwards !== this.scrollingDownwards) { + this.checkPoint = scroll; + } + + this.scrollingDownwards = scrollingDownwards; + this.position = scroll; + this.atTop = atTop; + + if (scrollingDownwards) { + if (!this.hiddenHeader && scroll - this.checkPoint > (this.scrollOffsetLimit + this.topGap)) { + this.hiddenHeader = true; + this._hideHeader(); + } + } else { + if (this.hiddenHeader && scroll - this.checkPoint < -(this.scrollOffsetLimit + this.topGap) / 2) { + this.hiddenHeader = false; + this._showHeader(); + } + } + + if (atTop && !this.atTop) { + // Force reshowing the invisible-on-scroll sections when reaching + // the top again + this._showHeader(); + } + }, +}); + +publicWidget.registry.DisappearingHeader = BaseDisappearingHeader.extend({ + selector: 'header.o_header_disappears', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _hideHeader: function () { + this._super(...arguments); + this.$el.css('transform', 'translate(0, -100%)'); + }, + /** + * @override + */ + _showHeader: function () { + this._super(...arguments); + this.$el.css('transform', this.atTop ? '' : `translate(0, -${this.topGap}px)`); + }, +}); + +publicWidget.registry.FadeOutHeader = BaseDisappearingHeader.extend({ + selector: 'header.o_header_fade_out', + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _hideHeader: function () { + this._super(...arguments); + this.$el.stop(false, true).fadeOut(); + }, + /** + * @override + */ + _showHeader: function () { + this._super(...arguments); + this.$el.css('transform', this.atTop ? '' : `translate(0, -${this.topGap}px)`); + this.$el.stop(false, true).fadeIn(); + }, +}); + +/** + * Auto adapt the header layout so that elements are not wrapped on a new line. + */ +publicWidget.registry.autohideMenu = publicWidget.Widget.extend({ + selector: 'header#top', + disabledInEditableMode: false, + + /** + * @override + */ + async start() { + await this._super(...arguments); + this.$topMenu = this.$('#top_menu'); + this.noAutohide = this.$el.is('.o_no_autohide_menu'); + if (!this.noAutohide) { + await wUtils.onceAllImagesLoaded(this.$('.navbar'), this.$('.o_mega_menu, .o_offcanvas_logo_container, .dropdown-menu .o_lang_flag')); + + // The previous code will make sure we wait for images to be fully + // loaded before initializing the auto more menu. But in some cases, + // it is not enough, we also have to wait for fonts or even extra + // scripts. Those will have no impact on the feature in most cases + // though, so we will only update the auto more menu at that time, + // no wait for it to initialize the feature. + var $window = $(window); + $window.on('load.autohideMenu', function () { + $window.trigger('resize'); + }); + + dom.initAutoMoreMenu(this.$topMenu, {unfoldable: '.divider, .divider ~ li, .o_no_autohide_item'}); + } + this.$topMenu.removeClass('o_menu_loading'); + this.$topMenu.trigger('menu_loaded'); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + if (!this.noAutohide && this.$topMenu) { + $(window).off('.autohideMenu'); + dom.destroyAutoMoreMenu(this.$topMenu); + } + }, +}); + +/** + * Note: this works well with the affixMenu... by chance (menuDirection is + * called after alphabetically). + * + * @todo check bootstrap v4: maybe handled automatically now ? + */ +publicWidget.registry.menuDirection = publicWidget.Widget.extend({ + selector: 'header .navbar .nav', + disabledInEditableMode: false, + events: { + 'show.bs.dropdown': '_onDropdownShow', + }, + + /** + * @override + */ + start: function () { + this.defaultAlignment = this.$el.is('.ml-auto, .ml-auto ~ *') ? 'right' : 'left'; + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {string} alignment - either 'left' or 'right' + * @param {integer} liOffset + * @param {integer} liWidth + * @param {integer} menuWidth + * @param {integer} pageWidth + * @returns {boolean} + */ + _checkOpening: function (alignment, liOffset, liWidth, menuWidth, pageWidth) { + if (alignment === 'left') { + // Check if ok to open the dropdown to the right (no window overflow) + return (liOffset + menuWidth <= pageWidth); + } else { + // Check if ok to open the dropdown to the left (no window overflow) + return (liOffset + liWidth - menuWidth >= 0); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onDropdownShow: function (ev) { + var $li = $(ev.target); + var $menu = $li.children('.dropdown-menu'); + var liOffset = $li.offset().left; + var liWidth = $li.outerWidth(); + var menuWidth = $menu.outerWidth(); + var pageWidth = $('#wrapwrap').outerWidth(); + + $menu.removeClass('dropdown-menu-left dropdown-menu-right'); + + var alignment = this.defaultAlignment; + if ($li.nextAll(':visible').length === 0) { + // The dropdown is the last menu item, open to the left + alignment = 'right'; + } + + // If can't open in the current direction because it would overflow the + // page, change the direction. But if the other direction would do the + // same, change back the direction. + for (var i = 0; i < 2; i++) { + if (!this._checkOpening(alignment, liOffset, liWidth, menuWidth, pageWidth)) { + alignment = (alignment === 'left' ? 'right' : 'left'); + } + } + + $menu.addClass('dropdown-menu-' + alignment); + }, +}); + +publicWidget.registry.hoverableDropdown = animations.Animation.extend({ + selector: 'header.o_hoverable_dropdown', + disabledInEditableMode: false, + effects: [{ + startEvents: 'resize', + update: '_dropdownHover', + }], + events: { + 'mouseenter .dropdown': '_onMouseEnter', + 'mouseleave .dropdown': '_onMouseLeave', + }, + + /** + * @override + */ + start: function () { + this.$dropdownMenus = this.$el.find('.dropdown-menu'); + this.$dropdownToggles = this.$el.find('.dropdown-toggle'); + this._dropdownHover(); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _dropdownHover: function () { + if (config.device.size_class > config.device.SIZES.SM) { + this.$dropdownMenus.css('margin-top', '0'); + this.$dropdownMenus.css('top', 'unset'); + } else { + this.$dropdownMenus.css('margin-top', ''); + this.$dropdownMenus.css('top', ''); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onMouseEnter: function (ev) { + if (config.device.size_class <= config.device.SIZES.SM) { + return; + } + + const $dropdown = $(ev.currentTarget); + $dropdown.addClass('show'); + $dropdown.find(this.$dropdownToggles).attr('aria-expanded', 'true'); + $dropdown.find(this.$dropdownMenus).addClass('show'); + }, + /** + * @private + * @param {Event} ev + */ + _onMouseLeave: function (ev) { + if (config.device.size_class <= config.device.SIZES.SM) { + return; + } + + const $dropdown = $(ev.currentTarget); + $dropdown.removeClass('show'); + $dropdown.find(this.$dropdownToggles).attr('aria-expanded', 'false'); + $dropdown.find(this.$dropdownMenus).removeClass('show'); + }, +}); + +publicWidget.registry.HeaderMainCollapse = publicWidget.Widget.extend({ + selector: 'header#top', + events: { + 'show.bs.collapse #top_menu_collapse': '_onCollapseShow', + 'hidden.bs.collapse #top_menu_collapse': '_onCollapseHidden', + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onCollapseShow() { + this.el.classList.add('o_top_menu_collapse_shown'); + }, + /** + * @private + */ + _onCollapseHidden() { + this.el.classList.remove('o_top_menu_collapse_shown'); + }, +}); + +return { + extraMenuUpdateCallbacks: extraMenuUpdateCallbacks, +}; +}); diff --git a/addons/website/static/src/js/content/ripple_effect.js b/addons/website/static/src/js/content/ripple_effect.js new file mode 100644 index 00000000..2e61d5b7 --- /dev/null +++ b/addons/website/static/src/js/content/ripple_effect.js @@ -0,0 +1,72 @@ +odoo.define('website.ripple_effect', function (require) { +'use strict'; + +const publicWidget = require('web.public.widget'); + +publicWidget.registry.RippleEffect = publicWidget.Widget.extend({ + selector: '.btn, .dropdown-toggle, .dropdown-item', + events: { + 'click': '_onClick', + }, + duration: 350, + + /** + * @override + */ + start: async function () { + this.diameter = Math.max(this.$el.outerWidth(), this.$el.outerHeight()); + this.offsetX = this.$el.offset().left; + this.offsetY = this.$el.offset().top; + return this._super(...arguments); + }, + /** + * @override + */ + destroy: function () { + this._super(...arguments); + if (this.rippleEl) { + this.rippleEl.remove(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {boolean} [toggle] + */ + _toggleRippleEffect: function (toggle) { + this.el.classList.toggle('o_js_ripple_effect', toggle); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onClick: function (ev) { + if (!this.rippleEl) { + this.rippleEl = document.createElement('span'); + this.rippleEl.classList.add('o_ripple_item'); + this.rippleEl.style.animationDuration = `${this.duration}ms`; + this.rippleEl.style.width = `${this.diameter}px`; + this.rippleEl.style.height = `${this.diameter}px`; + this.el.appendChild(this.rippleEl); + } + + clearTimeout(this.timeoutID); + this._toggleRippleEffect(false); + + this.rippleEl.style.top = `${ev.pageY - this.offsetY - this.diameter / 2}px`; + this.rippleEl.style.left = `${ev.pageX - this.offsetX - this.diameter / 2}px`; + + this._toggleRippleEffect(true); + this.timeoutID = setTimeout(() => this._toggleRippleEffect(false), this.duration); + }, +}); +}); diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js new file mode 100644 index 00000000..5eb08630 --- /dev/null +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -0,0 +1,1092 @@ +odoo.define('website.content.snippets.animation', function (require) { +'use strict'; + +/** + * Provides a way to start JS code for snippets' initialization and animations. + */ + +var Class = require('web.Class'); +var config = require('web.config'); +var core = require('web.core'); +const dom = require('web.dom'); +var mixins = require('web.mixins'); +var publicWidget = require('web.public.widget'); +var utils = require('web.utils'); + +var qweb = core.qweb; + +// Initialize fallbacks for the use of requestAnimationFrame, +// cancelAnimationFrame and performance.now() +window.requestAnimationFrame = window.requestAnimationFrame + || window.webkitRequestAnimationFrame + || window.mozRequestAnimationFrame + || window.msRequestAnimationFrame + || window.oRequestAnimationFrame; +window.cancelAnimationFrame = window.cancelAnimationFrame + || window.webkitCancelAnimationFrame + || window.mozCancelAnimationFrame + || window.msCancelAnimationFrame + || window.oCancelAnimationFrame; +if (!window.performance || !window.performance.now) { + window.performance = { + now: function () { + return Date.now(); + } + }; +} + +/** + * Add the notion of edit mode to public widgets. + */ +publicWidget.Widget.include({ + /** + * Indicates if the widget should not be instantiated in edit. The default + * is true, indeed most (all?) defined widgets only want to initialize + * events and states which should not be active in edit mode (this is + * especially true for non-website widgets). + * + * @type {boolean} + */ + disabledInEditableMode: true, + /** + * Acts as @see Widget.events except that the events are only binded if the + * Widget instance is instanciated in edit mode. The property is not + * considered if @see disabledInEditableMode is false. + */ + edit_events: null, + /** + * Acts as @see Widget.events except that the events are only binded if the + * Widget instance is instanciated in readonly mode. The property only + * makes sense if @see disabledInEditableMode is false, you should simply + * use @see Widget.events otherwise. + */ + read_events: null, + + /** + * Initializes the events that will need to be binded according to the + * given mode. + * + * @constructor + * @param {Object} parent + * @param {Object} [options] + * @param {boolean} [options.editableMode=false] + * true if the page is in edition mode + */ + init: function (parent, options) { + this._super.apply(this, arguments); + + this.editableMode = this.options.editableMode || false; + var extraEvents = this.editableMode ? this.edit_events : this.read_events; + if (extraEvents) { + this.events = _.extend({}, this.events || {}, extraEvents); + } + }, +}); + +/** + * In charge of handling one animation loop using the requestAnimationFrame + * feature. This is used by the `Animation` class below and should not be called + * directly by an end developer. + * + * This uses a simple API: it can be started, stopped, played and paused. + */ +var AnimationEffect = Class.extend(mixins.ParentedMixin, { + /** + * @constructor + * @param {Object} parent + * @param {function} updateCallback - the animation update callback + * @param {string} [startEvents=scroll] + * space separated list of events which starts the animation loop + * @param {jQuery|DOMElement} [$startTarget=window] + * the element(s) on which the startEvents are listened + * @param {Object} [options] + * @param {function} [options.getStateCallback] + * a function which returns a value which represents the state of the + * animation, i.e. for two same value, no refreshing of the animation + * is needed. Can be used for optimization. If the $startTarget is + * the window element, this defaults to returning the current + * scoll offset of the window or the size of the window for the + * scroll and resize events respectively. + * @param {string} [options.endEvents] + * space separated list of events which pause the animation loop. If + * not given, the animation is stopped after a while (if no + * startEvents is received again) + * @param {jQuery|DOMElement} [options.$endTarget=$startTarget] + * the element(s) on which the endEvents are listened + */ + init: function (parent, updateCallback, startEvents, $startTarget, options) { + mixins.ParentedMixin.init.call(this); + this.setParent(parent); + + options = options || {}; + this._minFrameTime = 1000 / (options.maxFPS || 100); + + // Initialize the animation startEvents, startTarget, endEvents, endTarget and callbacks + this._updateCallback = updateCallback; + this.startEvents = startEvents || 'scroll'; + const mainScrollingElement = $().getScrollingElement()[0]; + const mainScrollingTarget = mainScrollingElement === document.documentElement ? window : mainScrollingElement; + this.$startTarget = $($startTarget ? $startTarget : this.startEvents === 'scroll' ? mainScrollingTarget : window); + if (options.getStateCallback) { + this._getStateCallback = options.getStateCallback; + } else if (this.startEvents === 'scroll' && this.$startTarget[0] === mainScrollingTarget) { + const $scrollable = this.$startTarget; + this._getStateCallback = function () { + return $scrollable.scrollTop(); + }; + } else if (this.startEvents === 'resize' && this.$startTarget[0] === window) { + this._getStateCallback = function () { + return { + width: window.innerWidth, + height: window.innerHeight, + }; + }; + } else { + this._getStateCallback = function () { + return undefined; + }; + } + this.endEvents = options.endEvents || false; + this.$endTarget = options.$endTarget ? $(options.$endTarget) : this.$startTarget; + + this._updateCallback = this._updateCallback.bind(parent); + this._getStateCallback = this._getStateCallback.bind(parent); + + // Add a namespace to events using the generated uid + this._uid = '_animationEffect' + _.uniqueId(); + this.startEvents = _processEvents(this.startEvents, this._uid); + if (this.endEvents) { + this.endEvents = _processEvents(this.endEvents, this._uid); + } + + function _processEvents(events, namespace) { + events = events.split(' '); + return _.each(events, function (e, index) { + events[index] += ('.' + namespace); + }).join(' '); + } + }, + /** + * @override + */ + destroy: function () { + mixins.ParentedMixin.destroy.call(this); + this.stop(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Initializes when the animation must be played and paused and initializes + * the animation first frame. + */ + start: function () { + // Initialize the animation first frame + this._paused = false; + this._rafID = window.requestAnimationFrame((function (t) { + this._update(t); + this._paused = true; + }).bind(this)); + + // Initialize the animation play/pause events + if (this.endEvents) { + /** + * If there are endEvents, the animation should begin playing when + * the startEvents are triggered on the $startTarget and pause when + * the endEvents are triggered on the $endTarget. + */ + this.$startTarget.on(this.startEvents, (function (e) { + if (this._paused) { + _.defer(this.play.bind(this, e)); + } + }).bind(this)); + this.$endTarget.on(this.endEvents, (function () { + if (!this._paused) { + _.defer(this.pause.bind(this)); + } + }).bind(this)); + } else { + /** + * Else, if there is no endEvents, the animation should begin playing + * when the startEvents are *continuously* triggered on the + * $startTarget or fully played once. To achieve this, the animation + * begins playing and is scheduled to pause after 2 seconds. If the + * startEvents are triggered during that time, this is not paused + * for another 2 seconds. This allows to describe an "effect" + * animation (which lasts less than 2 seconds) or an animation which + * must be playing *during* an event (scroll, mousemove, resize, + * repeated clicks, ...). + */ + var pauseTimer = null; + this.$startTarget.on(this.startEvents, _.throttle((function (e) { + this.play(e); + + clearTimeout(pauseTimer); + pauseTimer = _.delay((function () { + this.pause(); + pauseTimer = null; + }).bind(this), 2000); + }).bind(this), 250, {trailing: false})); + } + }, + /** + * Pauses the animation and destroys the attached events which trigger the + * animation to be played or paused. + */ + stop: function () { + this.$startTarget.off(this.startEvents); + if (this.endEvents) { + this.$endTarget.off(this.endEvents); + } + this.pause(); + }, + /** + * Forces the requestAnimationFrame loop to start. + * + * @param {Event} e - the event which triggered the animation to play + */ + play: function (e) { + this._newEvent = e; + if (!this._paused) { + return; + } + this._paused = false; + this._rafID = window.requestAnimationFrame(this._update.bind(this)); + this._lastUpdateTimestamp = undefined; + }, + /** + * Forces the requestAnimationFrame loop to stop. + */ + pause: function () { + if (this._paused) { + return; + } + this._paused = true; + window.cancelAnimationFrame(this._rafID); + this._lastUpdateTimestamp = undefined; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Callback which is repeatedly called by the requestAnimationFrame loop. + * It controls the max fps at which the animation is running and initializes + * the values that the update callback needs to describe the animation + * (state, elapsedTime, triggered event). + * + * @private + * @param {DOMHighResTimeStamp} timestamp + */ + _update: function (timestamp) { + if (this._paused) { + return; + } + this._rafID = window.requestAnimationFrame(this._update.bind(this)); + + // Check the elapsed time since the last update callback call. + // Consider it 0 if there is no info of last timestamp and leave this + // _update call if it was called too soon (would overflow the set max FPS). + var elapsedTime = 0; + if (this._lastUpdateTimestamp) { + elapsedTime = timestamp - this._lastUpdateTimestamp; + if (elapsedTime < this._minFrameTime) { + return; + } + } + + // Check the new animation state thanks to the get state callback and + // store its new value. If the state is the same as the previous one, + // leave this _update call, except if there is an event which triggered + // the "play" method again. + var animationState = this._getStateCallback(elapsedTime, this._newEvent); + if (!this._newEvent + && animationState !== undefined + && _.isEqual(animationState, this._animationLastState)) { + return; + } + this._animationLastState = animationState; + + // Call the update callback with frame parameters + this._updateCallback(this._animationLastState, elapsedTime, this._newEvent); + this._lastUpdateTimestamp = timestamp; // Save the timestamp at which the update callback was really called + this._newEvent = undefined; // Forget the event which triggered the last "play" call + }, +}); + +/** + * Also register AnimationEffect automatically (@see effects, _prepareEffects). + */ +var Animation = publicWidget.Widget.extend({ + /** + * The max FPS at which all the automatic animation effects will be + * running by default. + */ + maxFPS: 100, + /** + * @see this._prepareEffects + * + * @type {Object[]} + * @type {string} startEvents + * The names of the events which trigger the effect to begin playing. + * @type {string} [startTarget] + * A selector to find the target where to listen for the start events + * (if no selector, the window target will be used). If the whole + * $target of the animation should be used, use the 'selector' string. + * @type {string} [endEvents] + * The name of the events which trigger the end of the effect (if none + * is defined, the animation will stop after a while + * @see AnimationEffect.start). + * @type {string} [endTarget] + * A selector to find the target where to listen for the end events + * (if no selector, the startTarget will be used). If the whole + * $target of the animation should be used, use the 'selector' string. + * @type {string} update + * A string which refers to a method which will be used as the update + * callback for the effect. It receives 3 arguments: the animation + * state, the elapsedTime since last update and the event which + * triggered the animation (undefined if just a new update call + * without trigger). + * @type {string} [getState] + * The animation state is undefined by default, the scroll offset for + * the particular {startEvents: 'scroll'} effect and an object with + * width and height for the particular {startEvents: 'resize'} effect. + * There is the possibility to define the getState callback of the + * animation effect with this key. This allows to improve performance + * even further in some cases. + */ + effects: [], + + /** + * Initializes the animation. The method should not be called directly as + * called automatically on animation instantiation and on restart. + * + * Also, prepares animation's effects and start them if any. + * + * @override + */ + start: function () { + this._prepareEffects(); + _.each(this._animationEffects, function (effect) { + effect.start(); + }); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Registers `AnimationEffect` instances. + * + * This can be done by extending this method and calling the @see _addEffect + * method in it or, better, by filling the @see effects property. + * + * @private + */ + _prepareEffects: function () { + this._animationEffects = []; + + var self = this; + _.each(this.effects, function (desc) { + self._addEffect(self[desc.update], desc.startEvents, _findTarget(desc.startTarget), { + getStateCallback: desc.getState && self[desc.getState], + endEvents: desc.endEvents || undefined, + $endTarget: _findTarget(desc.endTarget), + maxFPS: self.maxFPS, + }); + + // Return the DOM element matching the selector in the form + // described above. + function _findTarget(selector) { + if (selector) { + if (selector === 'selector') { + return self.$target; + } + return self.$(selector); + } + return undefined; + } + }); + }, + /** + * Registers a new `AnimationEffect` according to given parameters. + * + * @private + * @see AnimationEffect.init + */ + _addEffect: function (updateCallback, startEvents, $startTarget, options) { + this._animationEffects.push( + new AnimationEffect(this, updateCallback, startEvents, $startTarget, options) + ); + }, +}); + +//:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: + +var registry = publicWidget.registry; + +registry.slider = publicWidget.Widget.extend({ + selector: '.carousel', + disabledInEditableMode: false, + edit_events: { + 'content_changed': '_onContentChanged', + }, + + /** + * @override + */ + start: function () { + this.$('img').on('load.slider', () => this._computeHeights()); + this._computeHeights(); + // Initialize carousel and pause if in edit mode. + this.$target.carousel(this.editableMode ? 'pause' : undefined); + $(window).on('resize.slider', _.debounce(() => this._computeHeights(), 250)); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + this.$('img').off('.slider'); + this.$target.carousel('pause'); + this.$target.removeData('bs.carousel'); + _.each(this.$('.carousel-item'), function (el) { + $(el).css('min-height', ''); + }); + $(window).off('.slider'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _computeHeights: function () { + var maxHeight = 0; + var $items = this.$('.carousel-item'); + $items.css('min-height', ''); + _.each($items, function (el) { + var $item = $(el); + var isActive = $item.hasClass('active'); + $item.addClass('active'); + var height = $item.outerHeight(); + if (height > maxHeight) { + maxHeight = height; + } + $item.toggleClass('active', isActive); + }); + $items.css('min-height', maxHeight); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onContentChanged: function (ev) { + this._computeHeights(); + }, +}); + +registry.Parallax = Animation.extend({ + selector: '.parallax', + disabledInEditableMode: false, + effects: [{ + startEvents: 'scroll', + update: '_onWindowScroll', + }], + + /** + * @override + */ + start: function () { + this._rebuild(); + $(window).on('resize.animation_parallax', _.debounce(this._rebuild.bind(this), 500)); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + $(window).off('.animation_parallax'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Prepares the background element which will scroll at a different speed + * according to the viewport dimensions and other snippet parameters. + * + * @private + */ + _rebuild: function () { + // Add/find bg DOM element to hold the parallax bg (support old v10.0 parallax) + this.$bg = this.$('> .s_parallax_bg'); + + // Get parallax speed + this.speed = parseFloat(this.$target.attr('data-scroll-background-ratio') || 0); + + // Reset offset if parallax effect will not be performed and leave + var noParallaxSpeed = (this.speed === 0 || this.speed === 1); + if (noParallaxSpeed) { + this.$bg.css({ + transform: '', + top: '', + bottom: '', + }); + return; + } + + // Initialize parallax data according to snippet and viewport dimensions + this.viewport = document.body.clientHeight - $('#wrapwrap').position().top; + this.visibleArea = [this.$target.offset().top]; + this.visibleArea.push(this.visibleArea[0] + this.$target.innerHeight() + this.viewport); + this.ratio = this.speed * (this.viewport / 10); + + // Provide a "safe-area" to limit parallax + const absoluteRatio = Math.abs(this.ratio); + this.$bg.css({ + top: -absoluteRatio, + bottom: -absoluteRatio, + }); + }, + + //-------------------------------------------------------------------------- + // Effects + //-------------------------------------------------------------------------- + + /** + * Describes how to update the snippet when the window scrolls. + * + * @private + * @param {integer} scrollOffset + */ + _onWindowScroll: function (scrollOffset) { + // Speed == 0 is no effect and speed == 1 is handled by CSS only + if (this.speed === 0 || this.speed === 1) { + return; + } + + // Perform translation if the element is visible only + var vpEndOffset = scrollOffset + this.viewport; + if (vpEndOffset >= this.visibleArea[0] + && vpEndOffset <= this.visibleArea[1]) { + this.$bg.css('transform', 'translateY(' + _getNormalizedPosition.call(this, vpEndOffset) + 'px)'); + } + + function _getNormalizedPosition(pos) { + // Normalize scroll in a 1 to 0 range + var r = (pos - this.visibleArea[1]) / (this.visibleArea[0] - this.visibleArea[1]); + // Normalize accordingly to current options + return Math.round(this.ratio * (2 * r - 1)); + } + }, +}); + +registry.mediaVideo = publicWidget.Widget.extend({ + selector: '.media_iframe_video', + + /** + * @override + */ + start: function () { + // TODO: this code should be refactored to make more sense and be better + // integrated with Odoo (this refactoring should be done in master). + + var def = this._super.apply(this, arguments); + if (this.$target.children('iframe').length) { + // There already is an <iframe/>, do nothing + return def; + } + + // Bug fix / compatibility: empty the <div/> element as all information + // to rebuild the iframe should have been saved on the <div/> element + this.$target.empty(); + + // Add extra content for size / edition + this.$target.append( + '<div class="css_editable_mode_display"> </div>' + + '<div class="media_iframe_video_size"> </div>' + ); + + // Rebuild the iframe. Depending on version / compatibility / instance, + // the src is saved in the 'data-src' attribute or the + // 'data-oe-expression' one (the latter is used as a workaround in 10.0 + // system but should obviously be reviewed in master). + this.$target.append($('<iframe/>', { + src: _.escape(this.$target.data('oe-expression') || this.$target.data('src')), + frameborder: '0', + allowfullscreen: 'allowfullscreen', + sandbox: 'allow-scripts allow-same-origin', // https://www.html5rocks.com/en/tutorials/security/sandboxed-iframes/ + })); + + return def; + }, +}); + +registry.backgroundVideo = publicWidget.Widget.extend({ + selector: '.o_background_video', + xmlDependencies: ['/website/static/src/xml/website.background.video.xml'], + disabledInEditableMode: false, + + /** + * @override + */ + start: function () { + var proms = [this._super(...arguments)]; + + this.videoSrc = this.el.dataset.bgVideoSrc; + this.iframeID = _.uniqueId('o_bg_video_iframe_'); + + this.isYoutubeVideo = this.videoSrc.indexOf('youtube') >= 0; + this.isMobileEnv = config.device.size_class <= config.device.SIZES.LG && config.device.touch; + if (this.isYoutubeVideo && this.isMobileEnv) { + this.videoSrc = this.videoSrc + "&enablejsapi=1"; + + if (!window.YT) { + var oldOnYoutubeIframeAPIReady = window.onYouTubeIframeAPIReady; + proms.push(new Promise(resolve => { + window.onYouTubeIframeAPIReady = () => { + if (oldOnYoutubeIframeAPIReady) { + oldOnYoutubeIframeAPIReady(); + } + return resolve(); + }; + })); + $('<script/>', { + src: 'https://www.youtube.com/iframe_api', + }).appendTo('head'); + } + } + + var throttledUpdate = _.throttle(() => this._adjustIframe(), 50); + + var $dropdownMenu = this.$el.closest('.dropdown-menu'); + if ($dropdownMenu.length) { + this.$dropdownParent = $dropdownMenu.parent(); + this.$dropdownParent.on('shown.bs.dropdown.backgroundVideo', throttledUpdate); + } + + $(window).on('resize.' + this.iframeID, throttledUpdate); + + return Promise.all(proms).then(() => this._appendBgVideo()); + }, + /** + * @override + */ + destroy: function () { + this._super.apply(this, arguments); + + if (this.$dropdownParent) { + this.$dropdownParent.off('.backgroundVideo'); + } + + $(window).off('resize.' + this.iframeID); + + if (this.$bgVideoContainer) { + this.$bgVideoContainer.remove(); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adjusts iframe sizes and position so that it fills the container and so + * that it is centered in it. + * + * @private + */ + _adjustIframe: function () { + if (!this.$iframe) { + return; + } + + this.$iframe.removeClass('show'); + + // Adjust the iframe + var wrapperWidth = this.$target.innerWidth(); + var wrapperHeight = this.$target.innerHeight(); + var relativeRatio = (wrapperWidth / wrapperHeight) / (16 / 9); + var style = {}; + if (relativeRatio >= 1.0) { + style['width'] = '100%'; + style['height'] = (relativeRatio * 100) + '%'; + style['left'] = '0'; + style['top'] = (-(relativeRatio - 1.0) / 2 * 100) + '%'; + } else { + style['width'] = ((1 / relativeRatio) * 100) + '%'; + style['height'] = '100%'; + style['left'] = (-((1 / relativeRatio) - 1.0) / 2 * 100) + '%'; + style['top'] = '0'; + } + this.$iframe.css(style); + + void this.$iframe[0].offsetWidth; // Force style addition + this.$iframe.addClass('show'); + }, + /** + * Append background video related elements to the target. + * + * @private + */ + _appendBgVideo: function () { + var $oldContainer = this.$bgVideoContainer || this.$('> .o_bg_video_container'); + this.$bgVideoContainer = $(qweb.render('website.background.video', { + videoSrc: this.videoSrc, + iframeID: this.iframeID, + })); + this.$iframe = this.$bgVideoContainer.find('.o_bg_video_iframe'); + this.$iframe.one('load', () => { + this.$bgVideoContainer.find('.o_bg_video_loading').remove(); + }); + this.$bgVideoContainer.prependTo(this.$target); + $oldContainer.remove(); + + this._adjustIframe(); + + // YouTube does not allow to auto-play video in mobile devices, so we + // have to play the video manually. + if (this.isMobileEnv && this.isYoutubeVideo) { + new window.YT.Player(this.iframeID, { + events: { + onReady: ev => ev.target.playVideo(), + } + }); + } + }, +}); + +registry.socialShare = publicWidget.Widget.extend({ + selector: '.oe_social_share', + xmlDependencies: ['/website/static/src/xml/website.share.xml'], + events: { + 'mouseenter': '_onMouseEnter', + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _bindSocialEvent: function () { + this.$('.oe_social_facebook').click($.proxy(this._renderSocial, this, 'facebook')); + this.$('.oe_social_twitter').click($.proxy(this._renderSocial, this, 'twitter')); + this.$('.oe_social_linkedin').click($.proxy(this._renderSocial, this, 'linkedin')); + }, + /** + * @private + */ + _render: function () { + this.$el.popover({ + content: qweb.render('website.social_hover', {medias: this.socialList}), + placement: 'bottom', + container: this.$el, + html: true, + trigger: 'manual', + animation: false, + }).popover("show"); + + this.$el.off('mouseleave.socialShare').on('mouseleave.socialShare', function () { + var self = this; + setTimeout(function () { + if (!$(".popover:hover").length) { + $(self).popover('dispose'); + } + }, 200); + }); + }, + /** + * @private + */ + _renderSocial: function (social) { + var url = this.$el.data('urlshare') || document.URL.split(/[?#]/)[0]; + url = encodeURIComponent(url); + var title = document.title.split(" | ")[0]; // get the page title without the company name + var hashtags = ' #' + document.title.split(" | ")[1].replace(' ', '') + ' ' + this.hashtags; // company name without spaces (for hashtag) + var socialNetworks = { + 'facebook': 'https://www.facebook.com/sharer/sharer.php?u=' + url, + 'twitter': 'https://twitter.com/intent/tweet?original_referer=' + url + '&text=' + encodeURIComponent(title + hashtags + ' - ') + url, + 'linkedin': 'https://www.linkedin.com/sharing/share-offsite/?url=' + url, + }; + if (!_.contains(_.keys(socialNetworks), social)) { + return; + } + var wHeight = 500; + var wWidth = 500; + window.open(socialNetworks[social], '', 'menubar=no, toolbar=no, resizable=yes, scrollbar=yes, height=' + wHeight + ',width=' + wWidth); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Called when the user hovers the animation element -> open the social + * links popover. + * + * @private + */ + _onMouseEnter: function () { + var social = this.$el.data('social'); + this.socialList = social ? social.split(',') : ['facebook', 'twitter', 'linkedin']; + this.hashtags = this.$el.data('hashtags') || ''; + + this._render(); + this._bindSocialEvent(); + }, +}); + +registry.anchorSlide = publicWidget.Widget.extend({ + selector: 'a[href^="/"][href*="#"], a[href^="#"]', + events: { + 'click': '_onAnimateClick', + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQuery} $el the element to scroll to. + * @param {string} [scrollValue='true'] scroll value + * @returns {Promise} + */ + async _scrollTo($el, scrollValue = 'true') { + return dom.scrollTo($el[0], { + duration: scrollValue === 'true' ? 500 : 0, + extraOffset: this._computeExtraOffset(), + }); + }, + /** + * @private + */ + _computeExtraOffset() { + return 0; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onAnimateClick: function (ev) { + if (this.$target[0].pathname !== window.location.pathname) { + return; + } + var hash = this.$target[0].hash; + if (!utils.isValidAnchor(hash)) { + return; + } + var $anchor = $(hash); + const scrollValue = $anchor.attr('data-anchor'); + if (!$anchor.length || !scrollValue) { + return; + } + ev.preventDefault(); + this._scrollTo($anchor, scrollValue); + }, +}); + +registry.FullScreenHeight = publicWidget.Widget.extend({ + selector: '.o_full_screen_height', + disabledInEditableMode: false, + + /** + * @override + */ + start() { + if (this.$el.outerHeight() > this._computeIdealHeight()) { + // Only initialize if taller than the ideal height as some extra css + // rules may alter the full-screen-height class behavior in some + // cases (blog...). + this._adaptSize(); + $(window).on('resize.FullScreenHeight', _.debounce(() => this._adaptSize(), 250)); + } + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + $(window).off('.FullScreenHeight'); + this.el.style.setProperty('min-height', ''); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _adaptSize() { + const height = this._computeIdealHeight(); + this.el.style.setProperty('min-height', `${height}px`, 'important'); + }, + /** + * @private + */ + _computeIdealHeight() { + const windowHeight = $(window).outerHeight(); + // Doing it that way allows to considerer fixed headers, hidden headers, + // connected users, ... + const firstContentEl = $('#wrapwrap > main > :first-child')[0]; // first child to consider the padding-top of main + const mainTopPos = firstContentEl.getBoundingClientRect().top + dom.closestScrollable(firstContentEl.parentNode).scrollTop; + return (windowHeight - mainTopPos); + }, +}); + +registry.ScrollButton = registry.anchorSlide.extend({ + selector: '.o_scroll_button', + + /** + * @override + */ + _onAnimateClick: function (ev) { + ev.preventDefault(); + const $nextElement = this.$el.closest('section').next(); + if ($nextElement.length) { + this._scrollTo($nextElement); + } + }, +}); + +registry.FooterSlideout = publicWidget.Widget.extend({ + selector: '#wrapwrap:has(.o_footer_slideout)', + disabledInEditableMode: false, + + /** + * @override + */ + async start() { + const $main = this.$('> main'); + const slideoutEffect = $main.outerHeight() >= $(window).outerHeight(); + this.el.classList.toggle('o_footer_effect_enable', slideoutEffect); + + // Add a pixel div over the footer, after in the DOM, so that the + // height of the footer is understood by Firefox sticky implementation + // (which it seems to not understand because of the combination of 3 + // items: the footer is the last :visible element in the #wrapwrap, the + // #wrapwrap uses flex layout and the #wrapwrap is the element with a + // scrollbar). + // TODO check if the hack is still needed by future browsers. + this.__pixelEl = document.createElement('div'); + this.__pixelEl.style.width = `1px`; + this.__pixelEl.style.height = `1px`; + this.__pixelEl.style.marginTop = `-1px`; + this.el.appendChild(this.__pixelEl); + + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this.el.classList.remove('o_footer_effect_enable'); + this.__pixelEl.remove(); + }, +}); + +registry.HeaderHamburgerFull = publicWidget.Widget.extend({ + selector: 'header:has(.o_header_hamburger_full_toggler):not(:has(.o_offcanvas_menu_toggler))', + events: { + 'click .o_header_hamburger_full_toggler': '_onToggleClick', + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onToggleClick() { + document.body.classList.add('overflow-hidden'); + setTimeout(() => $(window).trigger('scroll'), 100); + }, +}); + +registry.BottomFixedElement = publicWidget.Widget.extend({ + selector: '#wrapwrap', + + /** + * @override + */ + async start() { + this.$scrollingElement = $().getScrollingElement(); + this.__hideBottomFixedElements = _.debounce(() => this._hideBottomFixedElements(), 500); + this.$scrollingElement.on('scroll.bottom_fixed_element', this.__hideBottomFixedElements); + $(window).on('resize.bottom_fixed_element', this.__hideBottomFixedElements); + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this.$scrollingElement.off('.bottom_fixed_element'); + $(window).off('.bottom_fixed_element'); + $('.o_bottom_fixed_element').removeClass('o_bottom_fixed_element_hidden'); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Hides the elements that are fixed at the bottom of the screen if the + * scroll reaches the bottom of the page and if the elements hide a button. + * + * @private + */ + _hideBottomFixedElements() { + // Note: check in the whole DOM instead of #wrapwrap as unfortunately + // some things are still put outside of the #wrapwrap (like the livechat + // button which is the main reason of this code). + const $bottomFixedElements = $('.o_bottom_fixed_element'); + if (!$bottomFixedElements.length) { + return; + } + + $bottomFixedElements.removeClass('o_bottom_fixed_element_hidden'); + if ((this.$scrollingElement[0].offsetHeight + this.$scrollingElement[0].scrollTop) >= (this.$scrollingElement[0].scrollHeight - 2)) { + const buttonEls = [...this.$('.btn:visible')]; + for (const el of $bottomFixedElements) { + if (buttonEls.some(button => dom.areColliding(button, el))) { + el.classList.add('o_bottom_fixed_element_hidden'); + } + } + } + }, +}); + +return { + Widget: publicWidget.Widget, + Animation: Animation, + registry: registry, + + Class: Animation, // Deprecated +}; +}); diff --git a/addons/website/static/src/js/content/website_root.js b/addons/website/static/src/js/content/website_root.js new file mode 100644 index 00000000..c2844a49 --- /dev/null +++ b/addons/website/static/src/js/content/website_root.js @@ -0,0 +1,350 @@ +odoo.define('website.root', function (require) { +'use strict'; + +const ajax = require('web.ajax'); +const {_t} = require('web.core'); +var Dialog = require('web.Dialog'); +const KeyboardNavigationMixin = require('web.KeyboardNavigationMixin'); +const session = require('web.session'); +var publicRootData = require('web.public.root'); +require("web.zoomodoo"); + +var websiteRootRegistry = publicRootData.publicRootRegistry; + +var WebsiteRoot = publicRootData.PublicRoot.extend(KeyboardNavigationMixin, { + events: _.extend({}, KeyboardNavigationMixin.events, publicRootData.PublicRoot.prototype.events || {}, { + 'click .js_change_lang': '_onLangChangeClick', + 'click .js_publish_management .js_publish_btn': '_onPublishBtnClick', + 'click .js_multi_website_switch': '_onWebsiteSwitch', + 'shown.bs.modal': '_onModalShown', + }), + custom_events: _.extend({}, publicRootData.PublicRoot.prototype.custom_events || {}, { + 'gmap_api_request': '_onGMapAPIRequest', + 'gmap_api_key_request': '_onGMapAPIKeyRequest', + 'ready_to_clean_for_save': '_onWidgetsStopRequest', + 'seo_object_request': '_onSeoObjectRequest', + }), + + /** + * @override + */ + init() { + this.isFullscreen = false; + KeyboardNavigationMixin.init.call(this, { + autoAccessKeys: false, + }); + return this._super(...arguments); + }, + /** + * @override + */ + start: function () { + KeyboardNavigationMixin.start.call(this); + // Compatibility lang change ? + if (!this.$('.js_change_lang').length) { + var $links = this.$('.js_language_selector a:not([data-oe-id])'); + var m = $(_.min($links, function (l) { + return $(l).attr('href').length; + })).attr('href'); + $links.each(function () { + var $link = $(this); + var t = $link.attr('href'); + var l = (t === m) ? "default" : t.split('/')[1]; + $link.data('lang', l).addClass('js_change_lang'); + }); + } + + // Enable magnify on zommable img + this.$('.zoomable img[data-zoom]').zoomOdoo(); + + return this._super.apply(this, arguments); + }, + /** + * @override + */ + destroy() { + KeyboardNavigationMixin.destroy.call(this); + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _getContext: function (context) { + var html = document.documentElement; + return _.extend({ + 'website_id': html.getAttribute('data-website-id') | 0, + }, this._super.apply(this, arguments)); + }, + /** + * @override + */ + _getExtraContext: function (context) { + var html = document.documentElement; + return _.extend({ + 'editable': !!(html.dataset.editable || $('[data-oe-model]').length), // temporary hack, this should be done in python + 'translatable': !!html.dataset.translatable, + 'edit_translations': !!html.dataset.edit_translations, + }, this._super.apply(this, arguments)); + }, + /** + * @private + * @param {boolean} [refetch=false] + */ + async _getGMapAPIKey(refetch) { + if (refetch || !this._gmapAPIKeyProm) { + this._gmapAPIKeyProm = new Promise(async resolve => { + const data = await this._rpc({ + route: '/website/google_maps_api_key', + }); + resolve(JSON.parse(data).google_maps_api_key || ''); + }); + } + return this._gmapAPIKeyProm; + }, + /** + * @override + */ + _getPublicWidgetsRegistry: function (options) { + var registry = this._super.apply(this, arguments); + if (options.editableMode) { + return _.pick(registry, function (PublicWidget) { + return !PublicWidget.prototype.disabledInEditableMode; + }); + } + return registry; + }, + /** + * @private + * @param {boolean} [editableMode=false] + * @param {boolean} [refetch=false] + */ + async _loadGMapAPI(editableMode, refetch) { + // Note: only need refetch to reload a configured key and load the + // library. If the library was loaded with a correct key and that the + // key changes meanwhile... it will not work but we can agree the user + // can bother to reload the page at that moment. + if (refetch || !this._gmapAPILoading) { + this._gmapAPILoading = new Promise(async resolve => { + const key = await this._getGMapAPIKey(refetch); + + window.odoo_gmap_api_post_load = (async function odoo_gmap_api_post_load() { + await this._startWidgets(undefined, {editableMode: editableMode}); + resolve(key); + }).bind(this); + + if (!key) { + if (!editableMode && session.is_admin) { + this.displayNotification({ + type: 'warning', + sticky: true, + message: + $('<div/>').append( + $('<span/>', {text: _t("Cannot load google map.")}), + $('<br/>'), + $('<a/>', { + href: "/web#action=website.action_website_configuration", + text: _t("Check your configuration."), + }), + )[0].outerHTML, + }); + } + resolve(false); + this._gmapAPILoading = false; + return; + } + await ajax.loadJS(`https://maps.googleapis.com/maps/api/js?v=3.exp&libraries=places&callback=odoo_gmap_api_post_load&key=${key}`); + }); + } + return this._gmapAPILoading; + }, + /** + * Toggles the fullscreen mode. + * + * @private + * @param {boolean} state toggle fullscreen on/off (true/false) + */ + _toggleFullscreen(state) { + this.isFullscreen = state; + document.body.classList.add('o_fullscreen_transition'); + document.body.classList.toggle('o_fullscreen', this.isFullscreen); + document.body.style.overflowX = 'hidden'; + let resizing = true; + window.requestAnimationFrame(function resizeFunction() { + window.dispatchEvent(new Event('resize')); + if (resizing) { + window.requestAnimationFrame(resizeFunction); + } + }); + let stopResizing; + const onTransitionEnd = ev => { + if (ev.target === document.body && ev.propertyName === 'padding-top') { + stopResizing(); + } + }; + stopResizing = () => { + resizing = false; + document.body.style.overflowX = ''; + document.body.removeEventListener('transitionend', onTransitionEnd); + document.body.classList.remove('o_fullscreen_transition'); + }; + document.body.addEventListener('transitionend', onTransitionEnd); + // Safeguard in case the transitionend event doesn't trigger for whatever reason. + window.setTimeout(() => stopResizing(), 500); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + _onWidgetsStartRequest: function (ev) { + ev.data.options = _.clone(ev.data.options || {}); + ev.data.options.editableMode = ev.data.editableMode; + this._super.apply(this, arguments); + }, + /** + * @todo review + * @private + */ + _onLangChangeClick: function (ev) { + ev.preventDefault(); + + var $target = $(ev.currentTarget); + // retrieve the hash before the redirect + var redirect = { + lang: $target.data('url_code'), + url: encodeURIComponent($target.attr('href').replace(/[&?]edit_translations[^&?]+/, '')), + hash: encodeURIComponent(window.location.hash) + }; + window.location.href = _.str.sprintf("/website/lang/%(lang)s?r=%(url)s%(hash)s", redirect); + }, + /** + * @private + * @param {OdooEvent} ev + */ + async _onGMapAPIRequest(ev) { + ev.stopPropagation(); + const apiKey = await this._loadGMapAPI(ev.data.editableMode, ev.data.refetch); + ev.data.onSuccess(apiKey); + }, + /** + * @private + * @param {OdooEvent} ev + */ + async _onGMapAPIKeyRequest(ev) { + ev.stopPropagation(); + const apiKey = await this._getGMapAPIKey(ev.data.refetch); + ev.data.onSuccess(apiKey); + }, + /** + /** + * Checks information about the page SEO object. + * + * @private + * @param {OdooEvent} ev + */ + _onSeoObjectRequest: function (ev) { + var res = this._unslugHtmlDataObject('seo-object'); + ev.data.callback(res); + }, + /** + * Returns a model/id object constructed from html data attribute. + * + * @private + * @param {string} dataAttr + * @returns {Object} an object with 2 keys: model and id, or null + * if not found + */ + _unslugHtmlDataObject: function (dataAttr) { + var repr = $('html').data(dataAttr); + var match = repr && repr.match(/(.+)\((\d+),(.*)\)/); + if (!match) { + return null; + } + return { + model: match[1], + id: match[2] | 0, + }; + }, + /** + * @todo review + * @private + */ + _onPublishBtnClick: function (ev) { + ev.preventDefault(); + if (document.body.classList.contains('editor_enable')) { + return; + } + + var self = this; + var $data = $(ev.currentTarget).parents(".js_publish_management:first"); + this._rpc({ + route: $data.data('controller') || '/website/publish', + params: { + id: +$data.data('id'), + object: $data.data('object'), + }, + }) + .then(function (result) { + $data.toggleClass("css_unpublished css_published"); + $data.find('input').prop("checked", result); + $data.parents("[data-publish]").attr("data-publish", +result ? 'on' : 'off'); + if (result) { + self.displayNotification({ + type: 'success', + message: $data.data('description') ? + _.str.sprintf(_t("You've published your %s."), $data.data('description')) : + _t("Published with success."), + }); + } + }); + }, + /** + * @private + * @param {Event} ev + */ + _onWebsiteSwitch: function (ev) { + var websiteId = ev.currentTarget.getAttribute('website-id'); + var websiteDomain = ev.currentTarget.getAttribute('domain'); + let url = `/website/force/${websiteId}`; + if (websiteDomain && window.location.hostname !== websiteDomain) { + url = websiteDomain + url; + } + const path = window.location.pathname + window.location.search + window.location.hash; + window.location.href = $.param.querystring(url, {'path': path}); + }, + /** + * @private + * @param {Event} ev + */ + _onModalShown: function (ev) { + $(ev.target).addClass('modal_shown'); + }, + /** + * @override + */ + _onKeyDown(ev) { + if (!session.user_id) { + return; + } + // If document.body doesn't contain the element, it was probably removed as a consequence of pressing Esc. + // we don't want to toggle fullscreen as the removal (eg, closing a modal) is the intended action. + if (ev.keyCode !== $.ui.keyCode.ESCAPE || !document.body.contains(ev.target) || ev.target.closest('.modal')) { + return KeyboardNavigationMixin._onKeyDown.apply(this, arguments); + } + this._toggleFullscreen(!this.isFullscreen); + }, +}); + +return { + WebsiteRoot: WebsiteRoot, + websiteRootRegistry: websiteRootRegistry, +}; +}); diff --git a/addons/website/static/src/js/content/website_root_instance.js b/addons/website/static/src/js/content/website_root_instance.js new file mode 100644 index 00000000..fbbefb03 --- /dev/null +++ b/addons/website/static/src/js/content/website_root_instance.js @@ -0,0 +1,26 @@ +odoo.define('root.widget', function (require) { +'use strict'; + +const AbstractService = require('web.AbstractService'); +const env = require('web.public_env'); +var lazyloader = require('web.public.lazyloader'); +var websiteRootData = require('website.root'); + +/** + * Configure Owl with the public env + */ +owl.config.mode = env.isDebug() ? "dev" : "prod"; +owl.Component.env = env; + +/** + * Deploy services in the env + */ +AbstractService.prototype.deployServices(env); + +var websiteRoot = new websiteRootData.WebsiteRoot(null); +return lazyloader.allScriptsLoaded.then(function () { + return websiteRoot.attachTo(document.body).then(function () { + return websiteRoot; + }); +}); +}); |
