diff options
Diffstat (limited to 'devtools/server/actors/animation.js')
-rw-r--r-- | devtools/server/actors/animation.js | 751 |
1 files changed, 751 insertions, 0 deletions
diff --git a/devtools/server/actors/animation.js b/devtools/server/actors/animation.js new file mode 100644 index 000000000..642c4bcaf --- /dev/null +++ b/devtools/server/actors/animation.js @@ -0,0 +1,751 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Set of actors that expose the Web Animations API to devtools protocol + * clients. + * + * The |Animations| actor is the main entry point. It is used to discover + * animation players on given nodes. + * There should only be one instance per debugger server. + * + * The |AnimationPlayer| actor provides attributes and methods to inspect an + * animation as well as pause/resume/seek it. + * + * The Web Animation spec implementation is ongoing in Gecko, and so this set + * of actors should evolve when the implementation progresses. + * + * References: + * - WebAnimation spec: + * http://w3c.github.io/web-animations/ + * - WebAnimation WebIDL files: + * /dom/webidl/Animation*.webidl + */ + +const {Cu} = require("chrome"); +const promise = require("promise"); +const {Task} = require("devtools/shared/task"); +const protocol = require("devtools/shared/protocol"); +const {Actor, ActorClassWithSpec} = protocol; +const {animationPlayerSpec, animationsSpec} = require("devtools/shared/specs/animation"); +const events = require("sdk/event/core"); + +// Types of animations. +const ANIMATION_TYPES = { + CSS_ANIMATION: "cssanimation", + CSS_TRANSITION: "csstransition", + SCRIPT_ANIMATION: "scriptanimation", + UNKNOWN: "unknown" +}; +exports.ANIMATION_TYPES = ANIMATION_TYPES; + +/** + * The AnimationPlayerActor provides information about a given animation: its + * startTime, currentTime, current state, etc. + * + * Since the state of a player changes as the animation progresses it is often + * useful to call getCurrentState at regular intervals to get the current state. + * + * This actor also allows playing, pausing and seeking the animation. + */ +var AnimationPlayerActor = protocol.ActorClassWithSpec(animationPlayerSpec, { + /** + * @param {AnimationsActor} The main AnimationsActor instance + * @param {AnimationPlayer} The player object returned by getAnimationPlayers + */ + initialize: function (animationsActor, player) { + Actor.prototype.initialize.call(this, animationsActor.conn); + + this.onAnimationMutation = this.onAnimationMutation.bind(this); + + this.walker = animationsActor.walker; + this.player = player; + + // Listen to animation mutations on the node to alert the front when the + // current animation changes. + // If the node is a pseudo-element, then we listen on its parent with + // subtree:true (there's no risk of getting too many notifications in + // onAnimationMutation since we filter out events that aren't for the + // current animation). + this.observer = new this.window.MutationObserver(this.onAnimationMutation); + if (this.isPseudoElement) { + this.observer.observe(this.node.parentElement, + {animations: true, subtree: true}); + } else { + this.observer.observe(this.node, {animations: true}); + } + }, + + destroy: function () { + // Only try to disconnect the observer if it's not already dead (i.e. if the + // container view hasn't navigated since). + if (this.observer && !Cu.isDeadWrapper(this.observer)) { + this.observer.disconnect(); + } + this.player = this.observer = this.walker = null; + + Actor.prototype.destroy.call(this); + }, + + get isPseudoElement() { + return !this.player.effect.target.ownerDocument; + }, + + get node() { + if (this._node) { + return this._node; + } + + let node = this.player.effect.target; + + if (this.isPseudoElement) { + // The target is a CSSPseudoElement object which just has a property that + // points to its parent element and a string type (::before or ::after). + let treeWalker = this.walker.getDocumentWalker(node.parentElement); + while (treeWalker.nextNode()) { + let currentNode = treeWalker.currentNode; + if ((currentNode.nodeName === "_moz_generated_content_before" && + node.type === "::before") || + (currentNode.nodeName === "_moz_generated_content_after" && + node.type === "::after")) { + this._node = currentNode; + } + } + } else { + // The target is a DOM node. + this._node = node; + } + + return this._node; + }, + + get window() { + return this.node.ownerDocument.defaultView; + }, + + /** + * Release the actor, when it isn't needed anymore. + * Protocol.js uses this release method to call the destroy method. + */ + release: function () {}, + + form: function (detail) { + if (detail === "actorid") { + return this.actorID; + } + + let data = this.getCurrentState(); + data.actor = this.actorID; + + // If we know the WalkerActor, and if the animated node is known by it, then + // return its corresponding NodeActor ID too. + if (this.walker && this.walker.hasNode(this.node)) { + data.animationTargetNodeActorID = this.walker.getNode(this.node).actorID; + } + + return data; + }, + + isCssAnimation: function (player = this.player) { + return player instanceof this.window.CSSAnimation; + }, + + isCssTransition: function (player = this.player) { + return player instanceof this.window.CSSTransition; + }, + + isScriptAnimation: function (player = this.player) { + return player instanceof this.window.Animation && !( + player instanceof this.window.CSSAnimation || + player instanceof this.window.CSSTransition + ); + }, + + getType: function () { + if (this.isCssAnimation()) { + return ANIMATION_TYPES.CSS_ANIMATION; + } else if (this.isCssTransition()) { + return ANIMATION_TYPES.CSS_TRANSITION; + } else if (this.isScriptAnimation()) { + return ANIMATION_TYPES.SCRIPT_ANIMATION; + } + + return ANIMATION_TYPES.UNKNOWN; + }, + + /** + * Get the name of this animation. This can be either the animation.id + * property if it was set, or the keyframe rule name or the transition + * property. + * @return {String} + */ + getName: function () { + if (this.player.id) { + return this.player.id; + } else if (this.isCssAnimation()) { + return this.player.animationName; + } else if (this.isCssTransition()) { + return this.player.transitionProperty; + } + + return ""; + }, + + /** + * Get the animation duration from this player, in milliseconds. + * @return {Number} + */ + getDuration: function () { + return this.player.effect.getComputedTiming().duration; + }, + + /** + * Get the animation delay from this player, in milliseconds. + * @return {Number} + */ + getDelay: function () { + return this.player.effect.getComputedTiming().delay; + }, + + /** + * Get the animation endDelay from this player, in milliseconds. + * @return {Number} + */ + getEndDelay: function () { + return this.player.effect.getComputedTiming().endDelay; + }, + + /** + * Get the animation iteration count for this player. That is, how many times + * is the animation scheduled to run. + * @return {Number} The number of iterations, or null if the animation repeats + * infinitely. + */ + getIterationCount: function () { + let iterations = this.player.effect.getComputedTiming().iterations; + return iterations === "Infinity" ? null : iterations; + }, + + /** + * Get the animation iterationStart from this player, in ratio. + * That is offset of starting position of the animation. + * @return {Number} + */ + getIterationStart: function () { + return this.player.effect.getComputedTiming().iterationStart; + }, + + /** + * Get the animation easing from this player. + * @return {String} + */ + getEasing: function () { + return this.player.effect.timing.easing; + }, + + /** + * Get the animation fill mode from this player. + * @return {String} + */ + getFill: function () { + return this.player.effect.getComputedTiming().fill; + }, + + /** + * Get the animation direction from this player. + * @return {String} + */ + getDirection: function () { + return this.player.effect.getComputedTiming().direction; + }, + + getPropertiesCompositorStatus: function () { + let properties = this.player.effect.getProperties(); + return properties.map(prop => { + return { + property: prop.property, + runningOnCompositor: prop.runningOnCompositor, + warning: prop.warning + }; + }); + }, + + /** + * Return the current start of the Animation. + * @return {Object} + */ + getState: function () { + // Remember the startTime each time getState is called, it may be useful + // when animations get paused. As in, when an animation gets paused, its + // startTime goes back to null, but the front-end might still be interested + // in knowing what the previous startTime was. So everytime it is set, + // remember it and send it along with the newState. + if (this.player.startTime) { + this.previousStartTime = this.player.startTime; + } + + // Note that if you add a new property to the state object, make sure you + // add the corresponding property in the AnimationPlayerFront' initialState + // getter. + return { + type: this.getType(), + // startTime is null whenever the animation is paused or waiting to start. + startTime: this.player.startTime, + previousStartTime: this.previousStartTime, + currentTime: this.player.currentTime, + playState: this.player.playState, + playbackRate: this.player.playbackRate, + name: this.getName(), + duration: this.getDuration(), + delay: this.getDelay(), + endDelay: this.getEndDelay(), + iterationCount: this.getIterationCount(), + iterationStart: this.getIterationStart(), + fill: this.getFill(), + easing: this.getEasing(), + direction: this.getDirection(), + // animation is hitting the fast path or not. Returns false whenever the + // animation is paused as it is taken off the compositor then. + isRunningOnCompositor: + this.getPropertiesCompositorStatus() + .some(propState => propState.runningOnCompositor), + propertyState: this.getPropertiesCompositorStatus(), + // The document timeline's currentTime is being sent along too. This is + // not strictly related to the node's animationPlayer, but is useful to + // know the current time of the animation with respect to the document's. + documentCurrentTime: this.node.ownerDocument.timeline.currentTime + }; + }, + + /** + * Get the current state of the AnimationPlayer (currentTime, playState, ...). + * Note that the initial state is returned as the form of this actor when it + * is initialized. + * This protocol method only returns a trimed down version of this state in + * case some properties haven't changed since last time (since the front can + * reconstruct those). If you want the full state, use the getState method. + * @return {Object} + */ + getCurrentState: function () { + let newState = this.getState(); + + // If we've saved a state before, compare and only send what has changed. + // It's expected of the front to also save old states to re-construct the + // full state when an incomplete one is received. + // This is to minimize protocol traffic. + let sentState = {}; + if (this.currentState) { + for (let key in newState) { + if (typeof this.currentState[key] === "undefined" || + this.currentState[key] !== newState[key]) { + sentState[key] = newState[key]; + } + } + } else { + sentState = newState; + } + this.currentState = newState; + + return sentState; + }, + + /** + * Executed when the current animation changes, used to emit the new state + * the the front. + */ + onAnimationMutation: function (mutations) { + let isCurrentAnimation = animation => animation === this.player; + let hasCurrentAnimation = animations => animations.some(isCurrentAnimation); + let hasChanged = false; + + for (let {removedAnimations, changedAnimations} of mutations) { + if (hasCurrentAnimation(removedAnimations)) { + // Reset the local copy of the state on removal, since the animation can + // be kept on the client and re-added, its state needs to be sent in + // full. + this.currentState = null; + } + + if (hasCurrentAnimation(changedAnimations)) { + // Only consider the state has having changed if any of delay, duration, + // iterationcount or iterationStart has changed (for now at least). + let newState = this.getState(); + let oldState = this.currentState; + hasChanged = newState.delay !== oldState.delay || + newState.iterationCount !== oldState.iterationCount || + newState.iterationStart !== oldState.iterationStart || + newState.duration !== oldState.duration || + newState.endDelay !== oldState.endDelay; + break; + } + } + + if (hasChanged) { + events.emit(this, "changed", this.getCurrentState()); + } + }, + + /** + * Pause the player. + */ + pause: function () { + this.player.pause(); + return this.player.ready; + }, + + /** + * Play the player. + * This method only returns when the animation has left its pending state. + */ + play: function () { + this.player.play(); + return this.player.ready; + }, + + /** + * Simply exposes the player ready promise. + * + * When an animation is created/paused then played, there's a short time + * during which its playState is pending, before being set to running. + * + * If you either created a new animation using the Web Animations API or + * paused/played an existing one, and then want to access the playState, you + * might be interested to call this method. + * This is especially important for tests. + */ + ready: function () { + return this.player.ready; + }, + + /** + * Set the current time of the animation player. + */ + setCurrentTime: function (currentTime) { + // The spec is that the progress of animation is changed + // if the time of setCurrentTime is during the endDelay. + // We should prevent the time + // to make the same animation behavior as the original. + // Likewise, in case the time is less than 0. + const timing = this.player.effect.getComputedTiming(); + if (timing.delay < 0) { + currentTime += timing.delay; + } + if (currentTime < 0) { + currentTime = 0; + } else if (currentTime * this.player.playbackRate > timing.endTime) { + currentTime = timing.endTime; + } + this.player.currentTime = currentTime * this.player.playbackRate; + }, + + /** + * Set the playback rate of the animation player. + */ + setPlaybackRate: function (playbackRate) { + this.player.playbackRate = playbackRate; + }, + + /** + * Get data about the keyframes of this animation player. + * @return {Object} Returns a list of frames, each frame containing the list + * animated properties as well as the frame's offset. + */ + getFrames: function () { + return this.player.effect.getKeyframes(); + }, + + /** + * Get data about the animated properties of this animation player. + * @return {Array} Returns a list of animated properties. + * Each property contains a list of values and their offsets + */ + getProperties: function () { + return this.player.effect.getProperties().map(property => { + return {name: property.property, values: property.values}; + }); + } +}); + +exports.AnimationPlayerActor = AnimationPlayerActor; + +/** + * The Animations actor lists animation players for a given node. + */ +var AnimationsActor = exports.AnimationsActor = protocol.ActorClassWithSpec(animationsSpec, { + initialize: function(conn, tabActor) { + Actor.prototype.initialize.call(this, conn); + this.tabActor = tabActor; + + this.onWillNavigate = this.onWillNavigate.bind(this); + this.onNavigate = this.onNavigate.bind(this); + this.onAnimationMutation = this.onAnimationMutation.bind(this); + + this.allAnimationsPaused = false; + events.on(this.tabActor, "will-navigate", this.onWillNavigate); + events.on(this.tabActor, "navigate", this.onNavigate); + }, + + destroy: function () { + Actor.prototype.destroy.call(this); + events.off(this.tabActor, "will-navigate", this.onWillNavigate); + events.off(this.tabActor, "navigate", this.onNavigate); + + this.stopAnimationPlayerUpdates(); + this.tabActor = this.observer = this.actors = this.walker = null; + }, + + /** + * Since AnimationsActor doesn't have a protocol.js parent actor that takes + * care of its lifetime, implementing disconnect is required to cleanup. + */ + disconnect: function () { + this.destroy(); + }, + + /** + * Clients can optionally call this with a reference to their WalkerActor. + * If they do, then AnimationPlayerActor's forms are going to also include + * NodeActor IDs when the corresponding NodeActors do exist. + * This, in turns, is helpful for clients to avoid having to go back once more + * to the server to get a NodeActor for a particular animation. + * @param {WalkerActor} walker + */ + setWalkerActor: function (walker) { + this.walker = walker; + }, + + /** + * Retrieve the list of AnimationPlayerActor actors for currently running + * animations on a node and its descendants. + * Note that calling this method a second time will destroy all previously + * retrieved AnimationPlayerActors. Indeed, the lifecycle of these actors + * is managed here on the server and tied to getAnimationPlayersForNode + * being called. + * @param {NodeActor} nodeActor The NodeActor as defined in + * /devtools/server/actors/inspector + */ + getAnimationPlayersForNode: function (nodeActor) { + let animations = nodeActor.rawNode.getAnimations({subtree: true}); + + // Destroy previously stored actors + if (this.actors) { + this.actors.forEach(actor => actor.destroy()); + } + this.actors = []; + + for (let i = 0; i < animations.length; i++) { + let actor = AnimationPlayerActor(this, animations[i]); + this.actors.push(actor); + } + + // When a front requests the list of players for a node, start listening + // for animation mutations on this node to send updates to the front, until + // either getAnimationPlayersForNode is called again or + // stopAnimationPlayerUpdates is called. + this.stopAnimationPlayerUpdates(); + let win = nodeActor.rawNode.ownerDocument.defaultView; + this.observer = new win.MutationObserver(this.onAnimationMutation); + this.observer.observe(nodeActor.rawNode, { + animations: true, + subtree: true + }); + + return this.actors; + }, + + onAnimationMutation: function (mutations) { + let eventData = []; + let readyPromises = []; + + for (let {addedAnimations, removedAnimations} of mutations) { + for (let player of removedAnimations) { + // Note that animations are reported as removed either when they are + // actually removed from the node (e.g. css class removed) or when they + // are finished and don't have forwards animation-fill-mode. + // In the latter case, we don't send an event, because the corresponding + // animation can still be seeked/resumed, so we want the client to keep + // its reference to the AnimationPlayerActor. + if (player.playState !== "idle") { + continue; + } + + let index = this.actors.findIndex(a => a.player === player); + if (index !== -1) { + eventData.push({ + type: "removed", + player: this.actors[index] + }); + this.actors.splice(index, 1); + } + } + + for (let player of addedAnimations) { + // If the added player already exists, it means we previously filtered + // it out when it was reported as removed. So filter it out here too. + if (this.actors.find(a => a.player === player)) { + continue; + } + + // If the added player has the same name and target node as a player we + // already have, it means it's a transition that's re-starting. So send + // a "removed" event for the one we already have. + let index = this.actors.findIndex(a => { + let isSameType = a.player.constructor === player.constructor; + let isSameName = (a.isCssAnimation() && + a.player.animationName === player.animationName) || + (a.isCssTransition() && + a.player.transitionProperty === player.transitionProperty); + let isSameNode = a.player.effect.target === player.effect.target; + + return isSameType && isSameNode && isSameName; + }); + if (index !== -1) { + eventData.push({ + type: "removed", + player: this.actors[index] + }); + this.actors.splice(index, 1); + } + + let actor = AnimationPlayerActor(this, player); + this.actors.push(actor); + eventData.push({ + type: "added", + player: actor + }); + readyPromises.push(player.ready); + } + } + + if (eventData.length) { + // Let's wait for all added animations to be ready before telling the + // front-end. + Promise.all(readyPromises).then(() => { + events.emit(this, "mutations", eventData); + }); + } + }, + + /** + * After the client has called getAnimationPlayersForNode for a given DOM + * node, the actor starts sending animation mutations for this node. If the + * client doesn't want this to happen anymore, it should call this method. + */ + stopAnimationPlayerUpdates: function () { + if (this.observer && !Cu.isDeadWrapper(this.observer)) { + this.observer.disconnect(); + } + }, + + /** + * Iterates through all nodes below a given rootNode (optionally also in + * nested frames) and finds all existing animation players. + * @param {DOMNode} rootNode The root node to start iterating at. Animation + * players will *not* be reported for this node. + * @param {Boolean} traverseFrames Whether we should iterate through nested + * frames too. + * @return {Array} An array of AnimationPlayer objects. + */ + getAllAnimations: function (rootNode, traverseFrames) { + if (!traverseFrames) { + return rootNode.getAnimations({subtree: true}); + } + + let animations = []; + for (let {document} of this.tabActor.windows) { + animations = [...animations, ...document.getAnimations({subtree: true})]; + } + return animations; + }, + + onWillNavigate: function ({isTopLevel}) { + if (isTopLevel) { + this.stopAnimationPlayerUpdates(); + } + }, + + onNavigate: function ({isTopLevel}) { + if (isTopLevel) { + this.allAnimationsPaused = false; + } + }, + + /** + * Pause all animations in the current tabActor's frames. + */ + pauseAll: function () { + let readyPromises = []; + // Until the WebAnimations API provides a way to play/pause via the document + // timeline, we have to iterate through the whole DOM to find all players. + for (let player of + this.getAllAnimations(this.tabActor.window.document, true)) { + player.pause(); + readyPromises.push(player.ready); + } + this.allAnimationsPaused = true; + return promise.all(readyPromises); + }, + + /** + * Play all animations in the current tabActor's frames. + * This method only returns when animations have left their pending states. + */ + playAll: function () { + let readyPromises = []; + // Until the WebAnimations API provides a way to play/pause via the document + // timeline, we have to iterate through the whole DOM to find all players. + for (let player of + this.getAllAnimations(this.tabActor.window.document, true)) { + player.play(); + readyPromises.push(player.ready); + } + this.allAnimationsPaused = false; + return promise.all(readyPromises); + }, + + toggleAll: function () { + if (this.allAnimationsPaused) { + return this.playAll(); + } + return this.pauseAll(); + }, + + /** + * Toggle (play/pause) several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor objects. + * @param {Boolean} shouldPause If set to true, the players will be paused, + * otherwise they will be played. + */ + toggleSeveral: function (players, shouldPause) { + return promise.all(players.map(player => { + return shouldPause ? player.pause() : player.play(); + })); + }, + + /** + * Set the current time of several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor. + * @param {Number} time The new currentTime. + * @param {Boolean} shouldPause Should the players be paused too. + */ + setCurrentTimes: function (players, time, shouldPause) { + return promise.all(players.map(player => { + let pause = shouldPause ? player.pause() : promise.resolve(); + return pause.then(() => player.setCurrentTime(time)); + })); + }, + + /** + * Set the playback rate of several animations at the same time. + * @param {Array} players A list of AnimationPlayerActor. + * @param {Number} rate The new rate. + */ + setPlaybackRates: function (players, rate) { + for (let player of players) { + player.setPlaybackRate(rate); + } + } +}); |