diff options
Diffstat (limited to 'devtools/server/actors/animation.js')
-rw-r--r-- | devtools/server/actors/animation.js | 906 |
1 files changed, 906 insertions, 0 deletions
diff --git a/devtools/server/actors/animation.js b/devtools/server/actors/animation.js new file mode 100644 index 0000000000..d8de85fd73 --- /dev/null +++ b/devtools/server/actors/animation.js @@ -0,0 +1,906 @@ +/* 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 devtools 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://drafts.csswg.org/web-animations/ + * - WebAnimation WebIDL files: + * /dom/webidl/Animation*.webidl + */ + +const { Actor } = require("resource://devtools/shared/protocol.js"); +const { + animationPlayerSpec, + animationsSpec, +} = require("resource://devtools/shared/specs/animation.js"); + +const { + ANIMATION_TYPE_FOR_LONGHANDS, +} = require("resource://devtools/server/actors/animation-type-longhand.js"); + +// Types of animations. +const ANIMATION_TYPES = { + CSS_ANIMATION: "cssanimation", + CSS_TRANSITION: "csstransition", + SCRIPT_ANIMATION: "scriptanimation", + UNKNOWN: "unknown", +}; +exports.ANIMATION_TYPES = ANIMATION_TYPES; + +function getAnimationTypeForLonghand(property) { + // If this is a custom property, return "custom" for now as it's not straightforward + // to retrieve the proper animation type. + // TODO: We could compute the animation type from the registered property syntax (Bug 1875435) + if (property.startsWith("--")) { + return "custom"; + } + + for (const [type, props] of ANIMATION_TYPE_FOR_LONGHANDS) { + if (props.has(property)) { + return type; + } + } + throw new Error("Unknown longhand property name"); +} +exports.getAnimationTypeForLonghand = getAnimationTypeForLonghand; + +/** + * 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. + */ +class AnimationPlayerActor extends Actor { + /** + * @param {AnimationsActor} The main AnimationsActor instance + * @param {AnimationPlayer} The player object returned by getAnimationPlayers + * @param {Number} Time which animation created + */ + constructor(animationsActor, player, createdTime) { + super(animationsActor.conn, animationPlayerSpec); + + 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 }); + } + + this.createdTime = createdTime; + this.currentTimeAtCreated = player.currentTime; + } + + destroy() { + // 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; + + super.destroy(); + } + + get isPseudoElement() { + return !!this.player.effect.pseudoElement; + } + + get pseudoElemenName() { + if (!this.isPseudoElement) { + return null; + } + + return `_moz_generated_content_${this.player.effect.pseudoElement.replace( + /^::/, + "" + )}`; + } + + get node() { + if (!this.isPseudoElement) { + return this.player.effect.target; + } + + const pseudoElementName = this.pseudoElemenName; + const originatingElem = this.player.effect.target; + const treeWalker = this.walker.getDocumentWalker(originatingElem); + + // When the animated node is a pseudo-element, we need to walk the children + // of the target node and look for it. + for ( + let next = treeWalker.firstChild(); + next; + next = treeWalker.nextSibling() + ) { + if (next.nodeName === pseudoElementName) { + return next; + } + } + + console.warn( + `Pseudo element ${this.player.effect.pseudoElement} is not found` + ); + return originatingElem; + } + + get document() { + return this.node.ownerDocument; + } + + get window() { + return this.document.defaultView; + } + + /** + * Release the actor, when it isn't needed anymore. + * Protocol.js uses this release method to call the destroy method. + */ + release() {} + + form(detail) { + const 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(player = this.player) { + return player instanceof this.window.CSSAnimation; + } + + isCssTransition(player = this.player) { + return player instanceof this.window.CSSTransition; + } + + isScriptAnimation(player = this.player) { + return ( + player instanceof this.window.Animation && + !( + player instanceof this.window.CSSAnimation || + player instanceof this.window.CSSTransition + ) + ); + } + + getType() { + 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() { + 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() { + return this.player.effect.getComputedTiming().duration; + } + + /** + * Get the animation delay from this player, in milliseconds. + * @return {Number} + */ + getDelay() { + return this.player.effect.getComputedTiming().delay; + } + + /** + * Get the animation endDelay from this player, in milliseconds. + * @return {Number} + */ + getEndDelay() { + 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() { + const 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() { + return this.player.effect.getComputedTiming().iterationStart; + } + + /** + * Get the animation easing from this player. + * @return {String} + */ + getEasing() { + return this.player.effect.getComputedTiming().easing; + } + + /** + * Get the animation fill mode from this player. + * @return {String} + */ + getFill() { + return this.player.effect.getComputedTiming().fill; + } + + /** + * Get the animation direction from this player. + * @return {String} + */ + getDirection() { + return this.player.effect.getComputedTiming().direction; + } + + /** + * Get animation-timing-function from animated element if CSS Animations. + * @return {String} + */ + getAnimationTimingFunction() { + if (!this.isCssAnimation()) { + return null; + } + + let pseudo = null; + let target = this.player.effect.target; + if (target.type) { + // Animated element is a pseudo element. + pseudo = target.type; + target = target.element; + } + return this.window.getComputedStyle(target, pseudo).animationTimingFunction; + } + + getPropertiesCompositorStatus() { + const 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() { + const compositorStatus = this.getPropertiesCompositorStatus(); + // 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, + 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(), + animationTimingFunction: this.getAnimationTimingFunction(), + // animation is hitting the fast path or not. Returns false whenever the + // animation is paused as it is taken off the compositor then. + isRunningOnCompositor: compositorStatus.some( + propState => propState.runningOnCompositor + ), + propertyState: compositorStatus, + // 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, + // The time which this animation created. + createdTime: this.createdTime, + // The time which an animation's current time when this animation has created. + currentTimeAtCreated: this.currentTimeAtCreated, + properties: this.getProperties(), + }; + } + + /** + * 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() { + const 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 (const 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(mutations) { + const isCurrentAnimation = animation => animation === this.player; + const hasCurrentAnimation = animations => + animations.some(isCurrentAnimation); + let hasChanged = false; + + for (const { 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 effect timing properties, + // animationTimingFunction or playbackRate has changed. + const newState = this.getState(); + const oldState = this.currentState; + hasChanged = + newState.delay !== oldState.delay || + newState.iterationCount !== oldState.iterationCount || + newState.iterationStart !== oldState.iterationStart || + newState.duration !== oldState.duration || + newState.endDelay !== oldState.endDelay || + newState.direction !== oldState.direction || + newState.easing !== oldState.easing || + newState.fill !== oldState.fill || + newState.animationTimingFunction !== + oldState.animationTimingFunction || + newState.playbackRate !== oldState.playbackRate; + break; + } + } + + if (hasChanged) { + this.emit("changed", this.getCurrentState()); + } + } + + /** + * 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, their offsets and distances. + */ + getProperties() { + const properties = this.player.effect.getProperties().map(property => { + return { name: property.property, values: property.values }; + }); + + const DOMWindowUtils = this.window.windowUtils; + + // Fill missing keyframe with computed value. + for (const property of properties) { + let underlyingValue = null; + // Check only 0% and 100% keyframes. + [0, property.values.length - 1].forEach(index => { + const values = property.values[index]; + if (values.value !== undefined) { + return; + } + if (!underlyingValue) { + let pseudo = null; + let target = this.player.effect.target; + if (target.type) { + // This target is a pseudo element. + pseudo = target.type; + target = target.element; + } + const value = DOMWindowUtils.getUnanimatedComputedStyle( + target, + pseudo, + property.name, + DOMWindowUtils.FLUSH_NONE + ); + const animationType = getAnimationTypeForLonghand(property.name); + underlyingValue = + animationType === "float" ? parseFloat(value, 10) : value; + } + values.value = underlyingValue; + }); + } + + // Calculate the distance. + for (const property of properties) { + const propertyName = property.name; + const maxObject = { distance: -1 }; + for (let i = 0; i < property.values.length - 1; i++) { + const value1 = property.values[i].value; + for (let j = i + 1; j < property.values.length; j++) { + const value2 = property.values[j].value; + const distance = this.getDistance( + this.node, + propertyName, + value1, + value2, + DOMWindowUtils + ); + if (maxObject.distance >= distance) { + continue; + } + maxObject.distance = distance; + maxObject.value1 = value1; + maxObject.value2 = value2; + } + } + if (maxObject.distance === 0) { + // Distance is zero means that no values change or can't calculate the distance. + // In this case, we use the keyframe offset as the distance. + property.values.reduce((previous, current) => { + // If the current value is same as previous value, use previous distance. + current.distance = + current.value === previous.value + ? previous.distance + : current.offset; + return current; + }, property.values[0]); + continue; + } + const baseValue = + maxObject.value1 < maxObject.value2 + ? maxObject.value1 + : maxObject.value2; + for (const values of property.values) { + const value = values.value; + const distance = this.getDistance( + this.node, + propertyName, + baseValue, + value, + DOMWindowUtils + ); + values.distance = distance / maxObject.distance; + } + } + return properties; + } + + /** + * Get the animation types for a given list of CSS property names. + * @param {Array} propertyNames - CSS property names (e.g. background-color) + * @return {Object} Returns animation types (e.g. {"background-color": "rgb(0, 0, 0)"}. + */ + getAnimationTypes(propertyNames) { + const animationTypes = {}; + for (const propertyName of propertyNames) { + animationTypes[propertyName] = getAnimationTypeForLonghand(propertyName); + } + return animationTypes; + } + + /** + * Returns the distance of between value1, value2. + * @param {Object} target - dom element + * @param {String} propertyName - e.g. transform + * @param {String} value1 - e.g. translate(0px) + * @param {String} value2 - e.g. translate(10px) + * @param {Object} DOMWindowUtils + * @param {float} distance + */ + getDistance(target, propertyName, value1, value2, DOMWindowUtils) { + if (value1 === value2) { + return 0; + } + try { + const distance = DOMWindowUtils.computeAnimationDistance( + target, + propertyName, + value1, + value2 + ); + return distance; + } catch (e) { + // We can't compute the distance such the 'discrete' animation, + // 'auto' keyword and so on. + return 0; + } + } +} + +exports.AnimationPlayerActor = AnimationPlayerActor; + +/** + * The Animations actor lists animation players for a given node. + */ +exports.AnimationsActor = class AnimationsActor extends Actor { + constructor(conn, targetActor) { + super(conn, animationsSpec); + this.targetActor = targetActor; + + this.onWillNavigate = this.onWillNavigate.bind(this); + this.onNavigate = this.onNavigate.bind(this); + this.onAnimationMutation = this.onAnimationMutation.bind(this); + + this.allAnimationsPaused = false; + this.targetActor.on("will-navigate", this.onWillNavigate); + this.targetActor.on("navigate", this.onNavigate); + } + + destroy() { + super.destroy(); + this.targetActor.off("will-navigate", this.onWillNavigate); + this.targetActor.off("navigate", this.onNavigate); + + this.stopAnimationPlayerUpdates(); + this.targetActor = this.observer = this.actors = this.walker = null; + } + + /** + * 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(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(nodeActor) { + const animations = nodeActor.rawNode.getAnimations({ subtree: true }); + + // Destroy previously stored actors + if (this.actors) { + for (const actor of this.actors) { + actor.destroy(); + } + } + + this.actors = []; + + for (const animation of animations) { + const createdTime = this.getCreatedTime(animation); + const actor = new AnimationPlayerActor(this, animation, createdTime); + 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(); + // ownerGlobal doesn't exist in content privileged windows. + // eslint-disable-next-line mozilla/use-ownerGlobal + const 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(mutations) { + const eventData = []; + const readyPromises = []; + + for (const { addedAnimations, removedAnimations } of mutations) { + for (const 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; + } + + const 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 (const 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. + const index = this.actors.findIndex(a => { + const isSameType = a.player.constructor === player.constructor; + const isSameName = + (a.isCssAnimation() && + a.player.animationName === player.animationName) || + (a.isCssTransition() && + a.player.transitionProperty === player.transitionProperty); + const 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); + } + + const createdTime = this.getCreatedTime(player); + const actor = new AnimationPlayerActor(this, player, createdTime); + 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(() => { + this.emit("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() { + if (this.observer && !Cu.isDeadWrapper(this.observer)) { + this.observer.disconnect(); + } + } + + onWillNavigate({ isTopLevel }) { + if (isTopLevel) { + this.stopAnimationPlayerUpdates(); + } + } + + onNavigate({ isTopLevel }) { + if (isTopLevel) { + this.allAnimationsPaused = false; + } + } + + /** + * Pause given animations. + * + * @param {Array} actors A list of AnimationPlayerActor. + */ + pauseSome(actors) { + for (const { player } of actors) { + this.pauseSync(player); + } + + return this.waitForNextFrame(actors); + } + + /** + * Play given animations. + * + * @param {Array} actors A list of AnimationPlayerActor. + */ + playSome(actors) { + for (const { player } of actors) { + this.playSync(player); + } + + return this.waitForNextFrame(actors); + } + + /** + * 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(players, time, shouldPause) { + for (const actor of players) { + const player = actor.player; + + if (shouldPause) { + player.startTime = null; + } + + const currentTime = + player.playbackRate > 0 + ? time - actor.createdTime + : actor.createdTime - time; + player.currentTime = currentTime * Math.abs(player.playbackRate); + } + + return this.waitForNextFrame(players); + } + + /** + * Set the playback rate of several animations at the same time. + * @param {Array} actors A list of AnimationPlayerActor. + * @param {Number} rate The new rate. + */ + setPlaybackRates(players, rate) { + return Promise.all( + players.map(({ player }) => { + player.updatePlaybackRate(rate); + return player.ready; + }) + ); + } + + /** + * Pause given player synchronously. + * + * @param {Object} player + */ + pauseSync(player) { + player.startTime = null; + } + + /** + * Play given player synchronously. + * + * @param {Object} player + */ + playSync(player) { + if (!player.playbackRate) { + // We can not play with playbackRate zero. + return; + } + + // Play animation in a synchronous fashion by setting the start time directly. + const currentTime = player.currentTime || 0; + player.startTime = + player.timeline.currentTime - currentTime / player.playbackRate; + } + + /** + * Return created fime of given animaiton. + * + * @param {Object} animation + */ + getCreatedTime(animation) { + return ( + animation.startTime || + animation.timeline.currentTime - + animation.currentTime / animation.playbackRate + ); + } + + /** + * Wait for next animation frame. + * + * @param {Array} actors + * @return {Promise} which waits for next frame + */ + waitForNextFrame(actors) { + const promises = actors.map(actor => { + const doc = actor.document; + const win = actor.window; + const timeAtCurrent = doc.timeline.currentTime; + + return new Promise(resolve => { + win.requestAnimationFrame(() => { + if (timeAtCurrent === doc.timeline.currentTime) { + win.requestAnimationFrame(resolve); + } else { + resolve(); + } + }); + }); + }); + + return Promise.all(promises); + } +}; |