gecko-dev/browser/devtools/animationinspector/components.js
Edoardo Putti d06369c85e Bug 1121896 - Show when animations are running on the compositor in the animation panel. r=pbro
--HG--
extra : rebase_source : b64325cb65d0413d8802cde86285424267849de3
2015-06-17 01:40:00 -04:00

851 lines
24 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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/. */
/* globals ViewHelpers */
"use strict";
// Set of reusable UI components for the animation-inspector UI.
// All components in this module share a common API:
// 1. construct the component:
// let c = new ComponentName();
// 2. initialize the markup of the component in a given parent node:
// c.init(containerElement);
// 3. render the component, passing in some sort of state:
// This may be called over and over again when the state changes, to update
// the component output.
// c.render(state);
// 4. destroy the component:
// c.destroy();
const {Cu} = require("chrome");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
const {
createNode,
drawGraphElementBackground,
findOptimalTimeInterval
} = require("devtools/animationinspector/utils");
const STRINGS_URI = "chrome://browser/locale/devtools/animationinspector.properties";
const L10N = new ViewHelpers.L10N(STRINGS_URI);
const MILLIS_TIME_FORMAT_MAX_DURATION = 4000;
// The minimum spacing between 2 time graduation headers in the timeline (ms).
const TIME_GRADUATION_MIN_SPACING = 40;
/**
* UI component responsible for displaying and updating the player meta-data:
* name, duration, iterations, delay.
* The parent UI component for this should drive its updates by calling
* render(state) whenever it wants the component to update.
*/
function PlayerMetaDataHeader() {
// Store the various state pieces we need to only refresh the UI when things
// change.
this.state = {};
}
exports.PlayerMetaDataHeader = PlayerMetaDataHeader;
PlayerMetaDataHeader.prototype = {
init: function(containerEl) {
// The main title element.
this.el = createNode({
parent: containerEl,
attributes: {
"class": "animation-title"
}
});
// Animation name.
this.nameLabel = createNode({
parent: this.el,
nodeType: "span"
});
this.nameValue = createNode({
parent: this.el,
nodeType: "strong",
attributes: {
"style": "display:none;"
}
});
// Animation duration, delay and iteration container.
let metaData = createNode({
parent: this.el,
nodeType: "span",
attributes: {
"class": "meta-data"
}
});
// Animation is running on compositor
this.compositorIcon = createNode({
parent: metaData,
nodeType: "span",
attributes: {
"class": "compositor-icon",
"title": L10N.getStr("player.runningOnCompositorTooltip")
}
});
// Animation duration.
this.durationLabel = createNode({
parent: metaData,
nodeType: "span",
textContent: L10N.getStr("player.animationDurationLabel")
});
this.durationValue = createNode({
parent: metaData,
nodeType: "strong"
});
// Animation delay (hidden by default since there may not be a delay).
this.delayLabel = createNode({
parent: metaData,
nodeType: "span",
attributes: {
"style": "display:none;"
},
textContent: L10N.getStr("player.animationDelayLabel")
});
this.delayValue = createNode({
parent: metaData,
nodeType: "strong"
});
// Animation iteration count (also hidden by default since we don't display
// single iterations).
this.iterationLabel = createNode({
parent: metaData,
nodeType: "span",
attributes: {
"style": "display:none;"
},
textContent: L10N.getStr("player.animationIterationCountLabel")
});
this.iterationValue = createNode({
parent: metaData,
nodeType: "strong",
attributes: {
"style": "display:none;"
}
});
},
destroy: function() {
this.state = null;
this.el.remove();
this.el = null;
this.nameLabel = this.nameValue = null;
this.durationLabel = this.durationValue = null;
this.delayLabel = this.delayValue = null;
this.iterationLabel = this.iterationValue = null;
this.compositorIcon = null;
},
render: function(state) {
// Update the name if needed.
if (state.name !== this.state.name) {
if (state.name) {
// Animations (and transitions since bug 1122414) have names.
this.nameLabel.textContent = L10N.getStr("player.animationNameLabel");
this.nameValue.style.display = "inline";
this.nameValue.textContent = state.name;
} else {
// With older actors, Css transitions don't have names.
this.nameLabel.textContent = L10N.getStr("player.transitionNameLabel");
this.nameValue.style.display = "none";
}
}
// update the duration value if needed.
if (state.duration !== this.state.duration) {
this.durationValue.textContent = L10N.getFormatStr("player.timeLabel",
L10N.numberWithDecimals(state.duration / 1000, 2));
}
// Update the delay if needed.
if (state.delay !== this.state.delay) {
if (state.delay) {
this.delayLabel.style.display = "inline";
this.delayValue.style.display = "inline";
this.delayValue.textContent = L10N.getFormatStr("player.timeLabel",
L10N.numberWithDecimals(state.delay / 1000, 2));
} else {
// Hide the delay elements if there is no delay defined.
this.delayLabel.style.display = "none";
this.delayValue.style.display = "none";
}
}
// Update the iterationCount if needed.
if (state.iterationCount !== this.state.iterationCount) {
if (state.iterationCount !== 1) {
this.iterationLabel.style.display = "inline";
this.iterationValue.style.display = "inline";
let count = state.iterationCount ||
L10N.getStr("player.infiniteIterationCount");
this.iterationValue.innerHTML = count;
} else {
// Hide the iteration elements if iteration is 1.
this.iterationLabel.style.display = "none";
this.iterationValue.style.display = "none";
}
}
// Show the Running on compositor icon if needed.
if (state.isRunningOnCompositor !== this.state.isRunningOnCompositor) {
if (state.isRunningOnCompositor) {
this.compositorIcon.style.display = "inline";
} else {
// Hide the compositor icon
this.compositorIcon.style.display = "none";
}
}
this.state = state;
}
};
/**
* UI component responsible for displaying the playback rate drop-down in each
* player widget, updating it when the state changes, and emitting events when
* the user selects a new value.
* The parent UI component for this should drive its updates by calling
* render(state) whenever it wants the component to update.
*/
function PlaybackRateSelector() {
this.currentRate = null;
this.onSelectionChanged = this.onSelectionChanged.bind(this);
EventEmitter.decorate(this);
}
exports.PlaybackRateSelector = PlaybackRateSelector;
PlaybackRateSelector.prototype = {
PRESETS: [.1, .5, 1, 2, 5, 10],
init: function(containerEl) {
// This component is simple enough that we can re-create the markup every
// time it's rendered. So here we only store the parentEl.
this.parentEl = containerEl;
},
destroy: function() {
this.removeSelect();
this.parentEl = this.el = null;
},
removeSelect: function() {
if (this.el) {
this.el.removeEventListener("change", this.onSelectionChanged);
this.el.remove();
}
},
/**
* Get the ordered list of presets, including the current playbackRate if
* different from the existing presets.
*/
getCurrentPresets: function({playbackRate}) {
return [...new Set([...this.PRESETS, playbackRate])].sort((a, b) => a > b);
},
render: function(state) {
if (state.playbackRate === this.currentRate) {
return;
}
this.removeSelect();
this.el = createNode({
parent: this.parentEl,
nodeType: "select",
attributes: {
"class": "rate devtools-button"
}
});
for (let preset of this.getCurrentPresets(state)) {
let option = createNode({
parent: this.el,
nodeType: "option",
attributes: {
value: preset,
},
textContent: L10N.getFormatStr("player.playbackRateLabel", preset)
});
if (preset === state.playbackRate) {
option.setAttribute("selected", "");
}
}
this.el.addEventListener("change", this.onSelectionChanged);
this.currentRate = state.playbackRate;
},
onSelectionChanged: function() {
this.emit("rate-changed", parseFloat(this.el.value));
}
};
/**
* UI component responsible for displaying a preview of the target dom node of
* a given animation.
* @param {InspectorPanel} inspector Requires a reference to the inspector-panel
* to highlight and select the node, as well as refresh it when there are
* mutations.
* @param {Object} options Supported properties are:
* - compact {Boolean} Defaults to false. If true, nodes will be previewed like
* tag#id.class instead of <tag id="id" class="class">
*/
function AnimationTargetNode(inspector, options={}) {
this.inspector = inspector;
this.options = options;
this.onPreviewMouseOver = this.onPreviewMouseOver.bind(this);
this.onPreviewMouseOut = this.onPreviewMouseOut.bind(this);
this.onSelectNodeClick = this.onSelectNodeClick.bind(this);
this.onMarkupMutations = this.onMarkupMutations.bind(this);
EventEmitter.decorate(this);
}
exports.AnimationTargetNode = AnimationTargetNode;
AnimationTargetNode.prototype = {
init: function(containerEl) {
let document = containerEl.ownerDocument;
// Init the markup for displaying the target node.
this.el = createNode({
parent: containerEl,
attributes: {
"class": "animation-target"
}
});
// Icon to select the node in the inspector.
this.selectNodeEl = createNode({
parent: this.el,
nodeType: "span",
attributes: {
"class": "node-selector"
}
});
// Wrapper used for mouseover/out event handling.
this.previewEl = createNode({
parent: this.el,
nodeType: "span"
});
if (!this.options.compact) {
this.previewEl.appendChild(document.createTextNode("<"));
}
// Tag name.
this.tagNameEl = createNode({
parent: this.previewEl,
nodeType: "span",
attributes: {
"class": "tag-name theme-fg-color3"
}
});
// Id attribute container.
this.idEl = createNode({
parent: this.previewEl,
nodeType: "span"
});
if (!this.options.compact) {
createNode({
parent: this.idEl,
nodeType: "span",
attributes: {
"class": "attribute-name theme-fg-color2"
},
textContent: "id"
});
this.idEl.appendChild(document.createTextNode("=\""));
} else {
createNode({
parent: this.idEl,
nodeType: "span",
attributes: {
"class": "theme-fg-color2"
},
textContent: "#"
});
}
createNode({
parent: this.idEl,
nodeType: "span",
attributes: {
"class": "attribute-value theme-fg-color6"
}
});
if (!this.options.compact) {
this.idEl.appendChild(document.createTextNode("\""));
}
// Class attribute container.
this.classEl = createNode({
parent: this.previewEl,
nodeType: "span"
});
if (!this.options.compact) {
createNode({
parent: this.classEl,
nodeType: "span",
attributes: {
"class": "attribute-name theme-fg-color2"
},
textContent: "class"
});
this.classEl.appendChild(document.createTextNode("=\""));
} else {
createNode({
parent: this.classEl,
nodeType: "span",
attributes: {
"class": "theme-fg-color6"
},
textContent: "."
});
}
createNode({
parent: this.classEl,
nodeType: "span",
attributes: {
"class": "attribute-value theme-fg-color6"
}
});
if (!this.options.compact) {
this.classEl.appendChild(document.createTextNode("\""));
this.previewEl.appendChild(document.createTextNode(">"));
}
// Init events for highlighting and selecting the node.
this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver);
this.previewEl.addEventListener("mouseout", this.onPreviewMouseOut);
this.selectNodeEl.addEventListener("click", this.onSelectNodeClick);
// Start to listen for markupmutation events.
this.inspector.on("markupmutation", this.onMarkupMutations);
},
destroy: function() {
this.inspector.off("markupmutation", this.onMarkupMutations);
this.previewEl.removeEventListener("mouseover", this.onPreviewMouseOver);
this.previewEl.removeEventListener("mouseout", this.onPreviewMouseOut);
this.selectNodeEl.removeEventListener("click", this.onSelectNodeClick);
this.el.remove();
this.el = this.tagNameEl = this.idEl = this.classEl = null;
this.selectNodeEl = this.previewEl = null;
this.nodeFront = this.inspector = this.playerFront = null;
},
onPreviewMouseOver: function() {
if (!this.nodeFront) {
return;
}
this.inspector.toolbox.highlighterUtils.highlightNodeFront(this.nodeFront);
},
onPreviewMouseOut: function() {
this.inspector.toolbox.highlighterUtils.unhighlight();
},
onSelectNodeClick: function() {
if (!this.nodeFront) {
return;
}
this.inspector.selection.setNodeFront(this.nodeFront, "animationinspector");
},
onMarkupMutations: function(e, mutations) {
if (!this.nodeFront || !this.playerFront) {
return;
}
for (let {target} of mutations) {
if (target === this.nodeFront) {
// Re-render with the same nodeFront to update the output.
this.render(this.playerFront);
break;
}
}
},
render: Task.async(function*(playerFront) {
this.playerFront = playerFront;
this.nodeFront = undefined;
try {
this.nodeFront = yield this.inspector.walker.getNodeFromActor(
playerFront.actorID, ["node"]);
} catch (e) {
// We might have been destroyed in the meantime, or the node might not be
// found.
if (!this.el) {
console.warn("Cound't retrieve the animation target node, widget " +
"destroyed");
}
console.error(e);
return;
}
if (!this.nodeFront || !this.el) {
return;
}
let {tagName, attributes} = this.nodeFront;
this.tagNameEl.textContent = tagName.toLowerCase();
let idIndex = attributes.findIndex(({name}) => name === "id");
if (idIndex > -1 && attributes[idIndex].value) {
this.idEl.querySelector(".attribute-value").textContent =
attributes[idIndex].value;
this.idEl.style.display = "inline";
} else {
this.idEl.style.display = "none";
}
let classIndex = attributes.findIndex(({name}) => name === "class");
if (classIndex > -1 && attributes[classIndex].value) {
let value = attributes[classIndex].value;
if (this.options.compact) {
value = value.split(" ").join(".");
}
this.classEl.querySelector(".attribute-value").textContent = value;
this.classEl.style.display = "inline";
} else {
this.classEl.style.display = "none";
}
this.emit("target-retrieved");
})
};
/**
* The TimeScale helper object is used to know which size should something be
* displayed with in the animation panel, depending on the animations that are
* currently displayed.
* If there are 5 animations displayed, and the first one starts at 10000ms and
* the last one ends at 20000ms, then this helper can be used to convert any
* time in this range to a distance in pixels.
*
* For the helper to know how to convert, it needs to know all the animations.
* Whenever a new animation is added to the panel, addAnimation(state) should be
* called. reset() can be called to start over.
*/
let TimeScale = {
minStartTime: Infinity,
maxEndTime: 0,
/**
* Add a new animation to time scale.
* @param {Object} state A PlayerFront.state object.
*/
addAnimation: function({startTime, delay, duration, iterationCount}) {
this.minStartTime = Math.min(this.minStartTime, startTime);
let length = delay + (duration * (!iterationCount ? 1 : iterationCount));
this.maxEndTime = Math.max(this.maxEndTime, startTime + length);
},
/**
* Reset the current time scale.
*/
reset: function() {
this.minStartTime = Infinity;
this.maxEndTime = 0;
},
/**
* Convert a startTime to a distance in pixels, in the current time scale.
* @param {Number} time
* @param {Number} containerWidth The width of the container element.
* @return {Number}
*/
startTimeToDistance: function(time, containerWidth) {
time -= this.minStartTime;
return this.durationToDistance(time, containerWidth);
},
/**
* Convert a duration to a distance in pixels, in the current time scale.
* @param {Number} time
* @param {Number} containerWidth The width of the container element.
* @return {Number}
*/
durationToDistance: function(duration, containerWidth) {
return containerWidth * duration / (this.maxEndTime - this.minStartTime);
},
/**
* Convert a distance in pixels to a time, in the current time scale.
* @param {Number} distance
* @param {Number} containerWidth The width of the container element.
* @return {Number}
*/
distanceToTime: function(distance, containerWidth) {
return this.minStartTime +
((this.maxEndTime - this.minStartTime) * distance / containerWidth);
},
/**
* Convert a distance in pixels to a time, in the current time scale.
* The time will be relative to the current minimum start time.
* @param {Number} distance
* @param {Number} containerWidth The width of the container element.
* @return {Number}
*/
distanceToRelativeTime: function(distance, containerWidth) {
let time = this.distanceToTime(distance, containerWidth);
return time - this.minStartTime;
},
/**
* Depending on the time scale, format the given time as milliseconds or
* seconds.
* @param {Number} time
* @return {String} The formatted time string.
*/
formatTime: function(time) {
let duration = this.maxEndTime - this.minStartTime;
// Format in milliseconds if the total duration is short enough.
if (duration <= MILLIS_TIME_FORMAT_MAX_DURATION) {
return L10N.getFormatStr("timeline.timeGraduationLabel", time.toFixed(0));
}
// Otherwise format in seconds.
return L10N.getFormatStr("player.timeLabel", (time / 1000).toFixed(1));
}
};
exports.TimeScale = TimeScale;
/**
* UI component responsible for displaying a timeline for animations.
* The timeline is essentially a graph with time along the x axis and animations
* along the y axis.
* The time is represented with a graduation header at the top and a current
* time play head.
* Animations are organized by lines, with a left margin containing the preview
* of the target DOM element the animation applies to.
*/
function AnimationsTimeline(inspector) {
this.animations = [];
this.targetNodes = [];
this.inspector = inspector;
this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this);
}
exports.AnimationsTimeline = AnimationsTimeline;
AnimationsTimeline.prototype = {
init: function(containerEl) {
this.win = containerEl.ownerDocument.defaultView;
this.rootWrapperEl = createNode({
parent: containerEl,
attributes: {
"class": "animation-timeline"
}
});
this.timeHeaderEl = createNode({
parent: this.rootWrapperEl,
attributes: {
"class": "time-header"
}
});
this.animationsEl = createNode({
parent: this.rootWrapperEl,
nodeType: "ul",
attributes: {
"class": "animations"
}
});
},
destroy: function() {
this.unrender();
this.rootWrapperEl.remove();
this.animations = [];
this.rootWrapperEl = null;
this.timeHeaderEl = null;
this.animationsEl = null;
this.win = null;
this.inspector = null;
},
destroyTargetNodes: function() {
for (let targetNode of this.targetNodes) {
targetNode.destroy();
}
this.targetNodes = [];
},
unrender: function() {
for (let animation of this.animations) {
animation.off("changed", this.onAnimationStateChanged);
}
TimeScale.reset();
this.destroyTargetNodes();
this.animationsEl.innerHTML = "";
},
render: function(animations) {
this.unrender();
this.animations = animations;
if (!this.animations.length) {
return;
}
// Loop first to set the time scale for all current animations.
for (let {state} of animations) {
TimeScale.addAnimation(state);
}
this.drawHeaderAndBackground();
for (let animation of this.animations) {
animation.on("changed", this.onAnimationStateChanged);
// Each line contains the target animated node and the animation time
// block.
let animationEl = createNode({
parent: this.animationsEl,
nodeType: "li",
attributes: {
"class": "animation"
}
});
// Left sidebar for the animated node.
let animatedNodeEl = createNode({
parent: animationEl,
attributes: {
"class": "target"
}
});
let timeBlockEl = createNode({
parent: animationEl,
attributes: {
"class": "time-block"
}
});
this.drawTimeBlock(animation, timeBlockEl);
// Draw the animated node target.
let targetNode = new AnimationTargetNode(this.inspector, {compact: true});
targetNode.init(animatedNodeEl);
targetNode.render(animation);
// Save the targetNode so it can be destroyed later.
this.targetNodes.push(targetNode);
}
},
onAnimationStateChanged: function() {
// For now, simply re-render the component. The animation front's state has
// already been updated.
this.render(this.animations);
},
drawHeaderAndBackground: function() {
let width = this.timeHeaderEl.offsetWidth;
let scale = width / (TimeScale.maxEndTime - TimeScale.minStartTime);
drawGraphElementBackground(this.win.document, "time-graduations", width, scale);
// And the time graduation header.
this.timeHeaderEl.innerHTML = "";
let interval = findOptimalTimeInterval(scale, TIME_GRADUATION_MIN_SPACING);
for (let i = 0; i < width; i += interval) {
createNode({
parent: this.timeHeaderEl,
nodeType: "span",
attributes: {
"class": "time-tick",
"style": `left:${i}px`
},
textContent: TimeScale.formatTime(
TimeScale.distanceToRelativeTime(i, width))
});
}
},
drawTimeBlock: function({state}, el) {
let width = el.offsetWidth;
// Container for all iterations and delay. Positioned at the right start
// time.
let x = TimeScale.startTimeToDistance(state.startTime + (state.delay || 0),
width);
// With the right width (duration*duration).
let count = state.iterationCount || 1;
let w = TimeScale.durationToDistance(state.duration, width);
let iterations = createNode({
parent: el,
attributes: {
"class": "iterations" + (state.iterationCount ? "" : " infinite"),
// Individual iterations are represented by setting the size of the
// repeating linear-gradient.
"style": `left:${x}px;
width:${w * count}px;
background-size:${Math.max(w, 2)}px 100%;`
}
});
// The animation name is displayed over the iterations.
createNode({
parent: iterations,
attributes: {
"class": "name"
},
textContent: state.name
});
// Delay.
if (state.delay) {
let delay = TimeScale.durationToDistance(state.delay, width);
createNode({
parent: iterations,
attributes: {
"class": "delay",
"style": `left:-${delay}px;
width:${delay}px;`
}
});
}
}
};