summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/animation.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/animation.js')
-rw-r--r--devtools/server/actors/animation.js751
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);
+ }
+ }
+});