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 core = require('web.core'); var dom = require('web.dom'); var mixins = require('web.mixins'); var utils = require('web.utils'); var Widget = require('web.Widget'); 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 || function (callback) { setTimeout(callback, 10); }; window.cancelAnimationFrame = window.cancelAnimationFrame || window.webkitCancelAnimationFrame || window.mozCancelAnimationFrame || window.msCancelAnimationFrame || window.oCancelAnimationFrame || function (id) { clearTimeout(id); }; if (!window.performance || !window.performance.now) { window.performance = {now: function () { return Date.now(); }}; } /** * 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'; this.$startTarget = $($startTarget || window); this._getStateCallback = options.getStateCallback || ((this.startEvents === 'scroll' && this.$startTarget[0] === window) ? function () { return window.pageYOffset; } : false) || ((this.startEvents === 'resize' && this.$startTarget[0] === window) ? function () { return {width: window.innerWidth, height: window.innerHeight}; } : false) || 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 }, }); /** * Provides a way for executing code once a website DOM element is loaded in the * dom and handle the case where the website edit mode is triggered. * * Also register AnimationEffect automatically (@see effects, _prepareEffects). */ var Animation = Widget.extend({ /** * The selector attribute, if defined, allows to automatically create an * instance of this animation on page load for each DOM element which * matches this selector. The `Animation.$target` element will then be that * particular DOM element. This should be the main way of instantiating * `Animation` elements. */ selector: false, /** * Extension of @see Widget.events * * A description of the event handlers to bind/delegate once the widget * has been rendered:: * * 'click .hello .world': 'async _onHelloWorldClick', * _^_ _^_ _^_ _^_ * | | | | * | (Optional) jQuery | Handler method name * | delegate selector | * | |_ (Optional) space separated options * | * async: use the automatic system * |_ Event name with making handlers promise-ready (see * potential jQuery makeButtonHandler, makeAsyncHandler) * namespaces * * Note: the values may be replaced by a function declaration. This is * however a deprecated behavior. * * @type {Object} */ events: {}, /** * Acts as @see Widget.events except that the events are only binded if the * Animation instance is instanciated in edit mode. */ edit_events: {}, /** * Acts as @see Widget.events except that the events are only binded if the * Animation instance is instanciated in readonly mode. */ read_events: {}, /** * 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 events that will need to be binded according to the * given mode. * * @constructor * @param {Object} parent * @param {boolean} editableMode - true if the page is in edition mode */ init: function (parent, editableMode) { this._super.apply(this, arguments); this.editableMode = editableMode; if (editableMode) { this.events = _.extend({}, this.events || {}, this.edit_events || {}); } else { this.events = _.extend({}, this.events || {}, this.read_events || {}); } }, /** * 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); }, /** * Destroys the animation and basically restores the target to the state it * was before the start method was called (unlike standard widget, the * associated $el DOM is not removed). * * Also stops animation effects and destroys them if any. */ destroy: function () { var $oldel = this.$el; // The difference with the default behavior is that we unset the // associated element first so that: // 1) its events are unbinded // 2) it is not removed from the DOM this.setElement(null); this._super.apply(this, arguments); // Reassign the variables afterwards to allow extensions to use them // after calling the _super method this.$el = $oldel; this.el = $oldel[0]; this.$target = this.$el; this.target = this.el; }, /** * @override */ setElement: function () { this._super.apply(this, arguments); this.$target = this.$el; this.target = this.el; }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @see this.events * @override */ _delegateEvents: function () { var self = this; var originalEvents = this.events; var events = {}; _.each(this.events, function (method, event) { // If the method is a function, use the default Widget system if (typeof method !== 'string') { events[event] = method; return; } // If the method is only a function name without options, use the // default Widget system var methodOptions = method.split(' '); if (methodOptions.length <= 1) { events[event] = method; return; } // If the method has no meaningful options, use the default Widget // system var isAsync = _.contains(methodOptions, 'async'); if (!isAsync) { events[event] = method; return; } method = self.proxy(methodOptions[methodOptions.length - 1]); if (_.str.startsWith(event, 'click')) { // Protect click handler to be called multiple times by // mistake by the user and add a visual disabling effect // for buttons. method = dom.makeButtonHandler(method); } else { // Protect all handlers to be recalled while the previous // async handler call is not finished. method = dom.makeAsyncHandler(method); } events[event] = method; }); this.events = events; this._super.apply(this, arguments); this.events = originalEvents; }, /** * 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) ); }, }); //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: /** * The registry object contains the list of available animations. */ var registry = {}; registry.slider = Animation.extend({ selector: '.carousel', edit_events: { 'slid.bs.carousel': '_onEditionSlide', }, /** * @override */ start: function () { if (!this.editableMode) { this.$('img').on('load.slider', this._onImageLoaded.bind(this)); this._computeHeights(); } this.$target.carousel(); 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', ''); }); }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- /** * @private */ _computeHeights: function () { var maxHeight = 0; var $items = this.$('.carousel-item'); _.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); }); _.each($items, function (el) { $(el).css('min-height', maxHeight); }); }, //-------------------------------------------------------------------------- // Handlers //-------------------------------------------------------------------------- /** * @private */ _onEditionSlide: function () { this._computeHeights(); }, /** * @private */ _onImageLoaded: function () { this._computeHeights(); }, }); registry.parallax = Animation.extend({ selector: '.parallax', 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) if (!this.$bg || !this.$bg.length) { this.$bg = this.$('> .s_parallax_bg'); if (!this.$bg.length) { this.$bg = $('', { class: 's_parallax_bg' + (this.$target.hasClass('oe_custom_bg') ? ' oe_custom_bg' : ''), }).prependTo(this.$target); } } var urlTarget = this.$target.css('background-image'); if (urlTarget !== 'none') { this.$bg.css('background-image', urlTarget); } this.$target.css('background-image', 'none'); // 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 this.$target.toggleClass('s_parallax_is_fixed', this.speed === 1); if (this.speed === 0 || this.speed === 1) { 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.visible_area = [this.$target.offset().top]; this.visible_area.push(this.visible_area[0] + this.$target.innerHeight() + this.viewport); this.ratio = this.speed * (this.viewport / 10); // Provide a "safe-area" to limit parallax this.$bg.css({ top: -this.ratio, bottom: -this.ratio, }); }, //-------------------------------------------------------------------------- // 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.visible_area[0] && vpEndOffset <= this.visible_area[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.visible_area[1]) / (this.visible_area[0] - this.visible_area[1]); // Normalize accordingly to current options return Math.round(this.ratio * (2 * r - 1)); } }, }); registry.share = Animation.extend({ selector: '.s_share, .oe_share', // oe_share for compatibility /** * @override */ start: function () { var url_regex = /(\?(?:|.*&)(?:u|url|body)=)(.*?)(&|#|$)/; var title_regex = /(\?(?:|.*&)(?:title|text|subject)=)(.*?)(&|#|$)/; var url = encodeURIComponent(window.location.href); var title = encodeURIComponent($('title').text()); this.$('a').each(function () { var $a = $(this); $a.attr('href', function (i, href) { return href.replace(url_regex, function (match, a, b, c) { return a + url + c; }).replace(title_regex, function (match, a, b, c) { return a + title + c; }); }); if ($a.attr('target') && $a.attr('target').match(/_blank/i) && !$a.closest('.o_editable').length) { $a.on('click', function () { window.open(this.href,'','menubar=no,toolbar=no,resizable=yes,scrollbars=yes,height=550,width=600'); return false; }); } }); return this._super.apply(this, arguments); }, }); registry.mediaVideo = Animation.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 , do nothing return def; } // Bug fix / compatibility: empty the
element as all information // to rebuild the iframe should have been saved on the element this.$target.empty(); // Add extra content for size / edition this.$target.append( '