diff --git a/addons/website/static/src/js/website.snippets.animation.js b/addons/website/static/src/js/website.snippets.animation.js index 572e3a0f0ef..6a8a0257627 100644 --- a/addons/website/static/src/js/website.snippets.animation.js +++ b/addons/website/static/src/js/website.snippets.animation.js @@ -2,12 +2,279 @@ odoo.define('website.snippets.animation', function (require) { 'use strict'; var ajax = require('web.ajax'); +var Class = require('web.Class'); var core = require('web.core'); var base = require('web_editor.base'); var animation = require('web_editor.snippets.animation'); 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) { window.setTimeout(callback, 10); }; + +window.cancelAnimationFrame = window.cancelAnimationFrame + || window.webkitCancelAnimationFrame + || window.mozCancelAnimationFrame + || window.msCancelAnimationFrame + || window.oCancelAnimationFrame + || function (id) { window.clearTimeout(id); }; + +if (!window.performance || !window.performance.now) window.performance = {now: function () { return Date.now(); }}; + +/** + * The AnimationComponent class in 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. + * The component contains a simple API, it can be started, stopped, played and paused. + */ +var AnimationComponent = Class.extend({ + init: function (parent, updateCallback, startEvents, $startTarget, options) { + this.parent = parent; // The Animation class instance which is using this component + + options = options || {}; + this._minFrameTime = 1000 / (options.maxFPS || 100); + + // Initialize the animation startEvents, startTarget, endEvents, endTarget and callbacks + this._updateCallback = updateCallback || function () {}; + 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; + + // Add a namespace to events using the generated uid + this._uid = "_animationComponent" + _.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(" "); + } + }, + /** + * The start method 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 endEvent, 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, the schedule is delayed 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})); + } + }, + /** + * The stop method 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(); + }, + /** + * The play method forces the requestAnimationFrame loop to start. + * @param 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; + }, + /** + * The pause method forces the requestAnimationFrame loop to stop. + */ + pause: function () { + if (this._paused) return; + this._paused = true; + window.cancelAnimationFrame(this._rafID); + this._lastUpdateTimestamp = undefined; + }, + /** + * The private _update method is the 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). + * @param timestamp -> the DOMHighResTimeStamp timestamp at which the function is called + */ + _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.call(this.parent, 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.call(this.parent, 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 + }, +}); + +/** + * Extend the Animation class to be able to register AnimationComponent's automatically according to + * the methods it defines. Also allows to register them in more conventional ways by extending the + * _prepareComponents method and calling the _addComponent method. + */ +animation.Class.include({ + maxFPS: 100, // The max FPS at which all the automatic animation components will be running by default + + /** + * Extend the start method to prepare animation components and start them. + */ + start: function () { + this._super.apply(this, arguments); + + this._prepareComponents(); + _.each(this._animationComponents, function (component) { + component.start(); + }); + }, + /** + * Extend the start method to stop animation components and destroy them. + */ + stop: function () { + this._super.apply(this, arguments); + + _.each(this._animationComponents, function (component) { + component.stop(); + }); + this._animationComponents = []; + }, + /** + * The private _prepareComponents method is the place to register AnimationComponent instances. + * This can be done by extending this method and calling the _addComponent method in it or by + * defining particulary-named methods in the specialized Animation class. + * + * The automatic creation of components according to method names is working like this: + * - Search for methods which match the pattern: on_startEvent[_startTarget[_off_endEvent[_endTarget]]] where: + * - startEvent: the event name which triggers the animation to begin playing + * - startTarget (optional): the selector (see description below) to find the target where to listen + * for startEvent (if no selector, the window target will be used) + * - endEvent (optional): the event name which triggers the end of the animation (if none is defined, the + * animation will stop after a while, @see AnimationComponent.start) + * - endTarget (optional): the selector (see description below) to find the target where to listen + * for endEvent (if no selector, the startTarget will be used) + * - For all of these methods, register the appropriate animation component with the method implementation used + * as the update callback. The method 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). + * The animation state is undefined by default, the scroll offset for the particular "on_scroll" method and + * and object with width and height for the particular "on_resize" method. There is the possibility to define + * the getState callback of the animation component with this method. For the component created by the definition + * of the "on_abc_def", define the "get_state_for_on_abc_def" (so "get_state_for_" followed by the method name). + * This allows to improve performance even further in some cases. + * + * Selectors mentioned above can be: + * - the "selector" string: this tells the system to use the Animation instance $target element as target for the + * animation component + * - an underscore-separated list of classnames: for example "leftPanel_playBtn", the system will search inside + * the Animation instance $target element for a DOM which matches the selector ".leftPanel .playBtn" + */ + _prepareComponents: function () { + this._animationComponents = []; + + var self = this; + _.each(this.__proto__, function (callback, key) { + if (!_.isFunction(callback)) return; + var m = key.match(/^on_([^_]+)(?:_(.+?))?(?:_off_([^_]+)(?:_(.+))?)?$/); // match the pattern described above + if (!m) return; + + self._addComponent(callback, m[1], _target_from_ugly_selector(m[2]), { + getStateCallback: self["get_state_for_" + key] || undefined, + endEvents: m[3] || undefined, + $endTarget: _target_from_ugly_selector(m[4]), + maxFPS: self.maxFPS, + }); + + // Return the DOM element matching the selector in the form described above. + function _target_from_ugly_selector(selector) { + if (selector) { + if (selector === "selector") { + return self.$target; + } else { + return self.$(_.map(selector.split("_"), function (v) { + return "." + v; + }).join(" ")); + } + } + return undefined; + } + }); + }, + /** + * The private _addComponent method registers a new AnimationComponent according to given parameters + * @see AnimationComponent.init + */ + _addComponent: function (updateCallback, startEvents, $startTarget, options) { + this._animationComponents.push(new AnimationComponent(this, updateCallback, startEvents, $startTarget, options)); + }, +}); + function load_called_template () { var ids_or_xml_ids = _.uniq($("[data-oe-call]").map(function () {return $(this).data('oe-call');}).get()); if (ids_or_xml_ids.length) { @@ -36,10 +303,12 @@ animation.registry.slider = animation.Class.extend({ selector: ".carousel", start: function () { this.$target.carousel(); + return this._super.apply(this, arguments); }, stop: function () { this.$target.carousel('pause'); this.$target.removeData("bs.carousel"); + return this._super.apply(this, arguments); }, }); @@ -116,6 +385,7 @@ animation.registry.share = animation.Class.extend({ }); } }); + return this._super.apply(this, arguments); } }); @@ -127,6 +397,7 @@ animation.registry.media_video = animation.Class.extend({ var size = '