Bug 1155663 - Show animations as synchronized time blocks in animation inspector; r=bgrins

This is the first step towards the animation-inspector UI v3 (bug 1153271).
The new UI is still hidden behind a pref, and this change doesn't implement
everything that is in the current v2 UI.
This introduces a new Timeline graph to represent all currently animated nodes
below the currently selected node.
v2 used to show them as independent player widgets. With this patch, we now show
them as synchronized time blocks on a common time scale.
Each animation has a preview of the animated node in the left sidebar, and a time
block on the right, the width of which represents its duration. The animation name
is also displayed.
There's also a time graduations header and background that gives the user information
about how long do the animations last.
This change does *not* provide a way to know what's the currentTime nor a way to
set it yet.
This also makes the existing animationinspector tests that still make sense with
the new timeline-based UI run with the new UI pref on.
This commit is contained in:
Patrick Brosset 2015-06-11 15:45:57 +02:00
parent 94e06eb343
commit 89c946120b
23 changed files with 1206 additions and 241 deletions

View File

@ -114,6 +114,7 @@ let AnimationsController = {
"setPlaybackRate"); "setPlaybackRate");
this.hasTargetNode = yield target.actorHasMethod("domwalker", this.hasTargetNode = yield target.actorHasMethod("domwalker",
"getNodeFromActor"); "getNodeFromActor");
this.isNewUI = Services.prefs.getBoolPref("devtools.inspector.animationInspectorV3");
if (this.destroyed) { if (this.destroyed) {
console.warn("Could not fully initialize the AnimationsController"); console.warn("Could not fully initialize the AnimationsController");
@ -240,11 +241,15 @@ let AnimationsController = {
for (let {type, player} of changes) { for (let {type, player} of changes) {
if (type === "added") { if (type === "added") {
this.animationPlayers.push(player); this.animationPlayers.push(player);
player.startAutoRefresh(); if (!this.isNewUI) {
player.startAutoRefresh();
}
} }
if (type === "removed") { if (type === "removed") {
player.stopAutoRefresh(); if (!this.isNewUI) {
player.stopAutoRefresh();
}
yield player.release(); yield player.release();
let index = this.animationPlayers.indexOf(player); let index = this.animationPlayers.indexOf(player);
this.animationPlayers.splice(index, 1); this.animationPlayers.splice(index, 1);
@ -256,12 +261,20 @@ let AnimationsController = {
}), }),
startAllAutoRefresh: function() { startAllAutoRefresh: function() {
if (this.isNewUI) {
return;
}
for (let front of this.animationPlayers) { for (let front of this.animationPlayers) {
front.startAutoRefresh(); front.startAutoRefresh();
} }
}, },
stopAllAutoRefresh: function() { stopAllAutoRefresh: function() {
if (this.isNewUI) {
return;
}
for (let front of this.animationPlayers) { for (let front of this.animationPlayers) {
front.stopAutoRefresh(); front.stopAutoRefresh();
} }

View File

@ -3,14 +3,17 @@
/* This Source Code Form is subject to the terms of the Mozilla Public /* 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 * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* globals AnimationsController, document, performance, promise,
gToolbox, gInspector, requestAnimationFrame, cancelAnimationFrame, L10N */
"use strict"; "use strict";
const {createNode} = require("devtools/animationinspector/utils");
const { const {
PlayerMetaDataHeader, PlayerMetaDataHeader,
PlaybackRateSelector, PlaybackRateSelector,
AnimationTargetNode, AnimationTargetNode,
createNode AnimationsTimeline
} = require("devtools/animationinspector/components"); } = require("devtools/animationinspector/components");
/** /**
@ -22,7 +25,8 @@ let AnimationsPanel = {
initialize: Task.async(function*() { initialize: Task.async(function*() {
if (AnimationsController.destroyed) { if (AnimationsController.destroyed) {
console.warn("Could not initialize the animation-panel, controller was destroyed"); console.warn("Could not initialize the animation-panel, controller " +
"was destroyed");
return; return;
} }
if (this.initialized) { if (this.initialized) {
@ -45,13 +49,18 @@ let AnimationsPanel = {
this.togglePicker = hUtils.togglePicker.bind(hUtils); this.togglePicker = hUtils.togglePicker.bind(hUtils);
this.onPickerStarted = this.onPickerStarted.bind(this); this.onPickerStarted = this.onPickerStarted.bind(this);
this.onPickerStopped = this.onPickerStopped.bind(this); this.onPickerStopped = this.onPickerStopped.bind(this);
this.createPlayerWidgets = this.createPlayerWidgets.bind(this); this.refreshAnimations = this.refreshAnimations.bind(this);
this.toggleAll = this.toggleAll.bind(this); this.toggleAll = this.toggleAll.bind(this);
this.onTabNavigated = this.onTabNavigated.bind(this); this.onTabNavigated = this.onTabNavigated.bind(this);
this.startListeners(); this.startListeners();
yield this.createPlayerWidgets(); if (AnimationsController.isNewUI) {
this.animationsTimelineComponent = new AnimationsTimeline(gInspector);
this.animationsTimelineComponent.init(this.playersEl);
}
yield this.refreshAnimations();
this.initialized.resolve(); this.initialized.resolve();
@ -69,6 +78,11 @@ let AnimationsPanel = {
this.destroyed = promise.defer(); this.destroyed = promise.defer();
this.stopListeners(); this.stopListeners();
if (this.animationsTimelineComponent) {
this.animationsTimelineComponent.destroy();
this.animationsTimelineComponent = null;
}
yield this.destroyPlayerWidgets(); yield this.destroyPlayerWidgets();
this.playersEl = this.errorMessageEl = null; this.playersEl = this.errorMessageEl = null;
@ -79,7 +93,7 @@ let AnimationsPanel = {
startListeners: function() { startListeners: function() {
AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT, AnimationsController.on(AnimationsController.PLAYERS_UPDATED_EVENT,
this.createPlayerWidgets); this.refreshAnimations);
this.pickerButtonEl.addEventListener("click", this.togglePicker, false); this.pickerButtonEl.addEventListener("click", this.togglePicker, false);
gToolbox.on("picker-started", this.onPickerStarted); gToolbox.on("picker-started", this.onPickerStarted);
@ -91,7 +105,7 @@ let AnimationsPanel = {
stopListeners: function() { stopListeners: function() {
AnimationsController.off(AnimationsController.PLAYERS_UPDATED_EVENT, AnimationsController.off(AnimationsController.PLAYERS_UPDATED_EVENT,
this.createPlayerWidgets); this.refreshAnimations);
this.pickerButtonEl.removeEventListener("click", this.togglePicker, false); this.pickerButtonEl.removeEventListener("click", this.togglePicker, false);
gToolbox.off("picker-started", this.onPickerStarted); gToolbox.off("picker-started", this.onPickerStarted);
@ -122,16 +136,18 @@ let AnimationsPanel = {
toggleAll: Task.async(function*() { toggleAll: Task.async(function*() {
let btnClass = this.toggleAllButtonEl.classList; let btnClass = this.toggleAllButtonEl.classList;
// Toggling all animations is async and it may be some time before each of if (!AnimationsController.isNewUI) {
// the current players get their states updated, so toggle locally too, to // Toggling all animations is async and it may be some time before each of
// avoid the timelines from jumping back and forth. // the current players get their states updated, so toggle locally too, to
if (this.playerWidgets) { // avoid the timelines from jumping back and forth.
let currentWidgetStateChange = []; if (this.playerWidgets) {
for (let widget of this.playerWidgets) { let currentWidgetStateChange = [];
currentWidgetStateChange.push(btnClass.contains("paused") for (let widget of this.playerWidgets) {
? widget.play() : widget.pause()); currentWidgetStateChange.push(btnClass.contains("paused")
? widget.play() : widget.pause());
}
yield promise.all(currentWidgetStateChange).catch(Cu.reportError);
} }
yield promise.all(currentWidgetStateChange).catch(Cu.reportError);
} }
btnClass.toggle("paused"); btnClass.toggle("paused");
@ -142,14 +158,21 @@ let AnimationsPanel = {
this.toggleAllButtonEl.classList.remove("paused"); this.toggleAllButtonEl.classList.remove("paused");
}, },
createPlayerWidgets: Task.async(function*() { refreshAnimations: Task.async(function*() {
let done = gInspector.updating("animationspanel"); let done = gInspector.updating("animationspanel");
// Empty the whole panel first. // Empty the whole panel first.
this.hideErrorMessage(); this.hideErrorMessage();
yield this.destroyPlayerWidgets(); yield this.destroyPlayerWidgets();
// If there are no players to show, show the error message instead and return. // Re-render the timeline component.
if (this.animationsTimelineComponent) {
this.animationsTimelineComponent.render(
AnimationsController.animationPlayers);
}
// If there are no players to show, show the error message instead and
// return.
if (!AnimationsController.animationPlayers.length) { if (!AnimationsController.animationPlayers.length) {
this.displayErrorMessage(); this.displayErrorMessage();
this.emit(this.UI_UPDATED_EVENT); this.emit(this.UI_UPDATED_EVENT);
@ -157,17 +180,21 @@ let AnimationsPanel = {
return; return;
} }
// Otherwise, create player widgets. // Otherwise, create player widgets (only when isNewUI is false, the
this.playerWidgets = []; // timeline has already been re-rendered).
let initPromises = []; if (!AnimationsController.isNewUI) {
this.playerWidgets = [];
let initPromises = [];
for (let player of AnimationsController.animationPlayers) { for (let player of AnimationsController.animationPlayers) {
let widget = new PlayerWidget(player, this.playersEl); let widget = new PlayerWidget(player, this.playersEl);
initPromises.push(widget.initialize()); initPromises.push(widget.initialize());
this.playerWidgets.push(widget); this.playerWidgets.push(widget);
}
yield initPromises;
} }
yield initPromises;
this.emit(this.UI_UPDATED_EVENT); this.emit(this.UI_UPDATED_EVENT);
done(); done();
}), }),
@ -392,9 +419,8 @@ PlayerWidget.prototype = {
onPlayPauseBtnClick: function() { onPlayPauseBtnClick: function() {
if (this.player.state.playState === "running") { if (this.player.state.playState === "running") {
return this.pause(); return this.pause();
} else {
return this.play();
} }
return this.play();
}, },
onRewindBtnClick: function() { onRewindBtnClick: function() {
@ -406,7 +432,7 @@ PlayerWidget.prototype = {
let time = state.duration; let time = state.duration;
if (state.iterationCount) { if (state.iterationCount) {
time = state.iterationCount * state.duration; time = state.iterationCount * state.duration;
} }
this.setCurrentTime(time, true); this.setCurrentTime(time, true);
}, },
@ -466,7 +492,8 @@ PlayerWidget.prototype = {
*/ */
setCurrentTime: Task.async(function*(time, shouldPause) { setCurrentTime: Task.async(function*(time, shouldPause) {
if (!AnimationsController.hasSetCurrentTime) { if (!AnimationsController.hasSetCurrentTime) {
throw new Error("This server version doesn't support setting animations' currentTime"); throw new Error("This server version doesn't support setting " +
"animations' currentTime");
} }
if (shouldPause) { if (shouldPause) {
@ -492,7 +519,8 @@ PlayerWidget.prototype = {
*/ */
setPlaybackRate: function(rate) { setPlaybackRate: function(rate) {
if (!AnimationsController.hasSetPlaybackRate) { if (!AnimationsController.hasSetPlaybackRate) {
throw new Error("This server version doesn't support setting animations' playbackRate"); throw new Error("This server version doesn't support setting " +
"animations' playbackRate");
} }
return this.player.setPlaybackRate(rate); return this.player.setPlaybackRate(rate);

View File

@ -3,6 +3,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public /* 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 * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* globals ViewHelpers */
"use strict"; "use strict";
@ -19,11 +20,19 @@
// 4. destroy the component: // 4. destroy the component:
// c.destroy(); // c.destroy();
const {Cu} = require('chrome'); const {Cu} = require("chrome");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
const {
createNode,
drawGraphElementBackground,
findOptimalTimeInterval
} = require("devtools/animationinspector/utils");
const STRINGS_URI = "chrome://browser/locale/devtools/animationinspector.properties"; const STRINGS_URI = "chrome://browser/locale/devtools/animationinspector.properties";
const L10N = new ViewHelpers.L10N(STRINGS_URI); 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: * UI component responsible for displaying and updating the player meta-data:
@ -75,9 +84,9 @@ PlayerMetaDataHeader.prototype = {
// Animation duration. // Animation duration.
this.durationLabel = createNode({ this.durationLabel = createNode({
parent: metaData, parent: metaData,
nodeType: "span" nodeType: "span",
textContent: L10N.getStr("player.animationDurationLabel")
}); });
this.durationLabel.textContent = L10N.getStr("player.animationDurationLabel");
this.durationValue = createNode({ this.durationValue = createNode({
parent: metaData, parent: metaData,
@ -90,9 +99,9 @@ PlayerMetaDataHeader.prototype = {
nodeType: "span", nodeType: "span",
attributes: { attributes: {
"style": "display:none;" "style": "display:none;"
} },
textContent: L10N.getStr("player.animationDelayLabel")
}); });
this.delayLabel.textContent = L10N.getStr("player.animationDelayLabel");
this.delayValue = createNode({ this.delayValue = createNode({
parent: metaData, parent: metaData,
@ -106,9 +115,9 @@ PlayerMetaDataHeader.prototype = {
nodeType: "span", nodeType: "span",
attributes: { attributes: {
"style": "display:none;" "style": "display:none;"
} },
textContent: L10N.getStr("player.animationIterationCountLabel")
}); });
this.iterationLabel.textContent = L10N.getStr("player.animationIterationCountLabel");
this.iterationValue = createNode({ this.iterationValue = createNode({
parent: metaData, parent: metaData,
@ -224,7 +233,7 @@ PlaybackRateSelector.prototype = {
* different from the existing presets. * different from the existing presets.
*/ */
getCurrentPresets: function({playbackRate}) { getCurrentPresets: function({playbackRate}) {
return [...new Set([...this.PRESETS, playbackRate])].sort((a,b) => a > b); return [...new Set([...this.PRESETS, playbackRate])].sort((a, b) => a > b);
}, },
render: function(state) { render: function(state) {
@ -248,9 +257,9 @@ PlaybackRateSelector.prototype = {
nodeType: "option", nodeType: "option",
attributes: { attributes: {
value: preset, value: preset,
} },
textContent: L10N.getFormatStr("player.playbackRateLabel", preset)
}); });
option.textContent = L10N.getFormatStr("player.playbackRateLabel", preset);
if (preset === state.playbackRate) { if (preset === state.playbackRate) {
option.setAttribute("selected", ""); option.setAttribute("selected", "");
} }
@ -261,7 +270,7 @@ PlaybackRateSelector.prototype = {
this.currentRate = state.playbackRate; this.currentRate = state.playbackRate;
}, },
onSelectionChanged: function(e) { onSelectionChanged: function() {
this.emit("rate-changed", parseFloat(this.el.value)); this.emit("rate-changed", parseFloat(this.el.value));
} }
}; };
@ -272,9 +281,13 @@ PlaybackRateSelector.prototype = {
* @param {InspectorPanel} inspector Requires a reference to the inspector-panel * @param {InspectorPanel} inspector Requires a reference to the inspector-panel
* to highlight and select the node, as well as refresh it when there are * to highlight and select the node, as well as refresh it when there are
* mutations. * 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) { function AnimationTargetNode(inspector, options={}) {
this.inspector = inspector; this.inspector = inspector;
this.options = options;
this.onPreviewMouseOver = this.onPreviewMouseOver.bind(this); this.onPreviewMouseOver = this.onPreviewMouseOver.bind(this);
this.onPreviewMouseOut = this.onPreviewMouseOut.bind(this); this.onPreviewMouseOut = this.onPreviewMouseOut.bind(this);
@ -313,7 +326,9 @@ AnimationTargetNode.prototype = {
nodeType: "span" nodeType: "span"
}); });
this.previewEl.appendChild(document.createTextNode("<")); if (!this.options.compact) {
this.previewEl.appendChild(document.createTextNode("<"));
}
// Tag name. // Tag name.
this.tagNameEl = createNode({ this.tagNameEl = createNode({
@ -330,15 +345,26 @@ AnimationTargetNode.prototype = {
nodeType: "span" nodeType: "span"
}); });
createNode({ if (!this.options.compact) {
parent: this.idEl, createNode({
nodeType: "span", parent: this.idEl,
attributes: { nodeType: "span",
"class": "attribute-name theme-fg-color2" attributes: {
} "class": "attribute-name theme-fg-color2"
}).textContent = "id"; },
textContent: "id"
this.idEl.appendChild(document.createTextNode("=\"")); });
this.idEl.appendChild(document.createTextNode("=\""));
} else {
createNode({
parent: this.idEl,
nodeType: "span",
attributes: {
"class": "theme-fg-color2"
},
textContent: "#"
});
}
createNode({ createNode({
parent: this.idEl, parent: this.idEl,
@ -348,7 +374,9 @@ AnimationTargetNode.prototype = {
} }
}); });
this.idEl.appendChild(document.createTextNode("\"")); if (!this.options.compact) {
this.idEl.appendChild(document.createTextNode("\""));
}
// Class attribute container. // Class attribute container.
this.classEl = createNode({ this.classEl = createNode({
@ -356,15 +384,26 @@ AnimationTargetNode.prototype = {
nodeType: "span" nodeType: "span"
}); });
createNode({ if (!this.options.compact) {
parent: this.classEl, createNode({
nodeType: "span", parent: this.classEl,
attributes: { nodeType: "span",
"class": "attribute-name theme-fg-color2" attributes: {
} "class": "attribute-name theme-fg-color2"
}).textContent = "class"; },
textContent: "class"
this.classEl.appendChild(document.createTextNode("=\"")); });
this.classEl.appendChild(document.createTextNode("=\""));
} else {
createNode({
parent: this.classEl,
nodeType: "span",
attributes: {
"class": "theme-fg-color6"
},
textContent: "."
});
}
createNode({ createNode({
parent: this.classEl, parent: this.classEl,
@ -374,9 +413,10 @@ AnimationTargetNode.prototype = {
} }
}); });
this.classEl.appendChild(document.createTextNode("\"")); if (!this.options.compact) {
this.classEl.appendChild(document.createTextNode("\""));
this.previewEl.appendChild(document.createTextNode(">")); this.previewEl.appendChild(document.createTextNode(">"));
}
// Init events for highlighting and selecting the node. // Init events for highlighting and selecting the node.
this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver); this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver);
@ -430,73 +470,357 @@ AnimationTargetNode.prototype = {
} }
}, },
render: function(playerFront) { render: Task.async(function*(playerFront) {
this.playerFront = playerFront; this.playerFront = playerFront;
this.inspector.walker.getNodeFromActor(playerFront.actorID, ["node"]).then(nodeFront => { this.nodeFront = undefined;
// We might have been destroyed in the meantime, or the node might not be found.
if (!this.el || !nodeFront) {
return;
}
this.nodeFront = nodeFront; try {
let {tagName, attributes} = nodeFront; this.nodeFront = yield this.inspector.walker.getNodeFromActor(
playerFront.actorID, ["node"]);
this.tagNameEl.textContent = tagName.toLowerCase(); } catch (e) {
// We might have been destroyed in the meantime, or the node might not be
let idIndex = attributes.findIndex(({name}) => name === "id"); // found.
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) {
this.classEl.querySelector(".attribute-value").textContent =
attributes[classIndex].value;
this.classEl.style.display = "inline";
} else {
this.classEl.style.display = "none";
}
this.emit("target-retrieved");
}, e => {
this.nodeFront = null;
if (!this.el) { if (!this.el) {
console.warn("Cound't retrieve the animation target node, widget destroyed"); console.warn("Cound't retrieve the animation target node, widget " +
} else { "destroyed");
console.error(e);
} }
}); 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));
} }
}; };
/** /**
* DOM node creation helper function. * UI component responsible for displaying a timeline for animations.
* @param {Object} Options to customize the node to be created. * The timeline is essentially a graph with time along the x axis and animations
* - nodeType {String} Optional, defaults to "div", * along the y axis.
* - attributes {Object} Optional attributes object like * The time is represented with a graduation header at the top and a current
* {attrName1:value1, attrName2: value2, ...} * time play head.
* - parent {DOMNode} Mandatory node to append the newly created node to. * Animations are organized by lines, with a left margin containing the preview
* @return {DOMNode} The newly created node. * of the target DOM element the animation applies to.
*/ */
function createNode(options) { function AnimationsTimeline(inspector) {
if (!options.parent) { this.animations = [];
throw new Error("Missing parent DOMNode to create new node"); this.targetNodes = [];
} this.inspector = inspector;
let type = options.nodeType || "div"; this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this);
let node = options.parent.ownerDocument.createElement(type);
for (let name in options.attributes || {}) {
let value = options.attributes[name];
node.setAttribute(name, value);
}
options.parent.appendChild(node);
return node;
} }
exports.createNode = createNode; 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;`
}
});
}
}
};

View File

@ -8,4 +8,5 @@ BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
EXTRA_JS_MODULES.devtools.animationinspector += [ EXTRA_JS_MODULES.devtools.animationinspector += [
'components.js', 'components.js',
'utils.js',
] ]

View File

@ -8,17 +8,44 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {inspector, panel} = yield openAnimationInspector();
let {inspector, panel} = yield openAnimationInspector();
yield testEmptyPanel(inspector, panel);
({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI();
yield testEmptyPanel(inspector, panel, true);
});
function* testEmptyPanel(inspector, panel, isNewUI=false) {
info("Select node .still and check that the panel is empty"); info("Select node .still and check that the panel is empty");
let stillNode = yield getNodeFront(".still", inspector); let stillNode = yield getNodeFront(".still", inspector);
let onUpdated = panel.once(panel.UI_UPDATED_EVENT);
yield selectNode(stillNode, inspector); yield selectNode(stillNode, inspector);
ok(!panel.playerWidgets || !panel.playerWidgets.length, yield onUpdated;
"No player widgets displayed for a still node");
if (isNewUI) {
is(panel.animationsTimelineComponent.animations.length, 0,
"No animation players stored in the timeline component for a still node");
is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 0,
"No animation displayed in the timeline component for a still node");
} else {
ok(!panel.playerWidgets || !panel.playerWidgets.length,
"No player widgets displayed for a still node");
}
info("Select the comment text node and check that the panel is empty"); info("Select the comment text node and check that the panel is empty");
let commentNode = yield inspector.walker.previousSibling(stillNode); let commentNode = yield inspector.walker.previousSibling(stillNode);
onUpdated = panel.once(panel.UI_UPDATED_EVENT);
yield selectNode(commentNode, inspector); yield selectNode(commentNode, inspector);
ok(!panel.playerWidgets || !panel.playerWidgets.length, yield onUpdated;
"No player widgets displayed for a text node");
}); if (isNewUI) {
is(panel.animationsTimelineComponent.animations.length, 0,
"No animation players stored in the timeline component for a text node");
is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 0,
"No animation displayed in the timeline component for a text node");
} else {
ok(!panel.playerWidgets || !panel.playerWidgets.length,
"No player widgets displayed for a text node");
}
}

View File

@ -15,4 +15,13 @@ add_task(function*() {
ok(panel, "The animation panel exists"); ok(panel, "The animation panel exists");
ok(panel.playersEl, "The animation panel has been initialized"); ok(panel.playersEl, "The animation panel has been initialized");
({panel, controller}) = yield closeAnimationInspectorAndRestartWithNewUI();
ok(controller, "The animation controller exists");
ok(controller.animationsFront, "The animation controller has been initialized");
ok(panel, "The animation panel exists");
ok(panel.playersEl, "The animation panel has been initialized");
ok(panel.animationsTimelineComponent, "The animation panel has been initialized");
}); });

View File

@ -10,8 +10,15 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {inspector, panel, controller} = yield openAnimationInspector();
let ui = yield openAnimationInspector();
yield testEventsOrder(ui);
ui = yield closeAnimationInspectorAndRestartWithNewUI();
yield testEventsOrder(ui);
});
function* testEventsOrder({inspector, panel, controller}) {
info("Listen for the players-updated, ui-updated and inspector-updated events"); info("Listen for the players-updated, ui-updated and inspector-updated events");
let receivedEvents = []; let receivedEvents = [];
controller.once(controller.PLAYERS_UPDATED_EVENT, () => { controller.once(controller.PLAYERS_UPDATED_EVENT, () => {
@ -19,7 +26,7 @@ add_task(function*() {
}); });
panel.once(panel.UI_UPDATED_EVENT, () => { panel.once(panel.UI_UPDATED_EVENT, () => {
receivedEvents.push(panel.UI_UPDATED_EVENT); receivedEvents.push(panel.UI_UPDATED_EVENT);
}) });
inspector.once("inspector-updated", () => { inspector.once("inspector-updated", () => {
receivedEvents.push("inspector-updated"); receivedEvents.push("inspector-updated");
}); });
@ -36,4 +43,4 @@ add_task(function*() {
"The second event received was the ui-updated event"); "The second event received was the ui-updated event");
is(receivedEvents[2], "inspector-updated", is(receivedEvents[2], "inspector-updated",
"The third event received was the inspector-updated event"); "The third event received was the inspector-updated event");
}); }

View File

@ -9,7 +9,14 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_body_animation.html"); yield addTab(TEST_URL_ROOT + "doc_body_animation.html");
let {panel} = yield openAnimationInspector();
is(panel.playerWidgets.length, 1, "One animation player is displayed after init"); let {panel} = yield openAnimationInspector();
is(panel.playerWidgets.length, 1,
"One animation player is displayed after init");
({panel}) = yield closeAnimationInspectorAndRestartWithNewUI();
is(panel.animationsTimelineComponent.animations.length, 1,
"One animation is handled by the timeline after init");
is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 1,
"One animation is displayed after init");
}); });

View File

@ -27,5 +27,26 @@ add_task(function*() {
"The target element's content is correct"); "The target element's content is correct");
let selectorEl = targetEl.querySelector(".node-selector"); let selectorEl = targetEl.querySelector(".node-selector");
ok(selectorEl, "The icon to select the target element in the inspector exists"); ok(selectorEl,
"The icon to select the target element in the inspector exists");
info("Test again with the new timeline UI");
({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI();
info("Select the simple animated node");
yield selectNode(".animated", inspector);
let targetNodeComponent = panel.animationsTimelineComponent.targetNodes[0];
// Make sure to wait for the target-retrieved event if the nodeFront hasn't
// yet been retrieved by the TargetNodeComponent.
if (!targetNodeComponent.nodeFront) {
yield targetNodeComponent.once("target-retrieved");
}
is(targetNodeComponent.el.textContent, "div#.ball.animated",
"The target element's content is correct");
selectorEl = targetNodeComponent.el.querySelector(".node-selector");
ok(selectorEl,
"The icon to select the target element in the inspector exists");
}); });

View File

@ -8,13 +8,19 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {toolbox, inspector, panel} = yield openAnimationInspector();
let {inspector, panel} = yield openAnimationInspector();
yield testRefreshOnNewAnimation(inspector, panel);
({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI();
yield testRefreshOnNewAnimation(inspector, panel);
});
function* testRefreshOnNewAnimation(inspector, panel) {
info("Select a non animated node"); info("Select a non animated node");
yield selectNode(".still", inspector); yield selectNode(".still", inspector);
is(panel.playersEl.querySelectorAll(".player-widget").length, 0, assertAnimationsDisplayed(panel, 0);
"There are no player widgets in the panel");
info("Listen to the next UI update event"); info("Listen to the next UI update event");
let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
@ -29,6 +35,14 @@ add_task(function*() {
yield onPanelUpdated; yield onPanelUpdated;
ok(true, "The panel update event was fired"); ok(true, "The panel update event was fired");
is(panel.playersEl.querySelectorAll(".player-widget").length, 1, assertAnimationsDisplayed(panel, 1);
"There is one player widget in the panel");
}); info("Remove the animation class on the node");
onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
yield executeInContent("devtools:test:setAttribute", {
selector: ".ball.animated",
attributeName: "class",
attributeValue: "ball still"
});
yield onPanelUpdated;
}

View File

@ -8,13 +8,21 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {toolbox, inspector, panel} = yield openAnimationInspector();
let {inspector, panel} = yield openAnimationInspector();
yield testRefreshOnRemove(inspector, panel);
yield testAddedAnimationWorks(inspector, panel);
info("Reload and test again with the new UI");
({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI(true);
yield testRefreshOnRemove(inspector, panel, true);
});
function* testRefreshOnRemove(inspector, panel) {
info("Select a animated node"); info("Select a animated node");
yield selectNode(".animated", inspector); yield selectNode(".animated", inspector);
is(panel.playersEl.querySelectorAll(".player-widget").length, 1, assertAnimationsDisplayed(panel, 1);
"There is one player widget in the panel");
info("Listen to the next UI update event"); info("Listen to the next UI update event");
let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
@ -29,23 +37,24 @@ add_task(function*() {
yield onPanelUpdated; yield onPanelUpdated;
ok(true, "The panel update event was fired"); ok(true, "The panel update event was fired");
is(panel.playersEl.querySelectorAll(".player-widget").length, 0, assertAnimationsDisplayed(panel, 0);
"There are no player widgets in the panel anymore");
info("Add an finite animation on the node again, and wait for it to appear"); info("Add an finite animation on the node again, and wait for it to appear");
onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
yield executeInContent("devtools:test:setAttribute", { yield executeInContent("devtools:test:setAttribute", {
selector: ".test-node", selector: ".test-node",
attributeName: "class", attributeName: "class",
attributeValue: "ball short" attributeValue: "ball short test-node"
}); });
yield onPanelUpdated; yield onPanelUpdated;
is(panel.playersEl.querySelectorAll(".player-widget").length, 1,
"There is one player widget in the panel again");
assertAnimationsDisplayed(panel, 1);
}
function* testAddedAnimationWorks(inspector, panel) {
info("Now wait until the animation finishes"); info("Now wait until the animation finishes");
let widget = panel.playerWidgets[0]; let widget = panel.playerWidgets[0];
yield waitForPlayState(widget.player, "finished") yield waitForPlayState(widget.player, "finished");
is(panel.playersEl.querySelectorAll(".player-widget").length, 1, is(panel.playersEl.querySelectorAll(".player-widget").length, 1,
"There is still a player widget in the panel after the animation finished"); "There is still a player widget in the panel after the animation finished");
@ -59,4 +68,4 @@ add_task(function*() {
EventUtils.synthesizeMouseAtCenter(input, {type: "mousedown"}, win); EventUtils.synthesizeMouseAtCenter(input, {type: "mousedown"}, win);
yield onPaused; yield onPaused;
ok(widget.el.classList.contains("paused"), "The widget is in paused mode"); ok(widget.el.classList.contains("paused"), "The widget is in paused mode");
}); }

View File

@ -8,8 +8,15 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {toolbox, inspector, panel} = yield openAnimationInspector();
let {inspector, panel} = yield openAnimationInspector();
yield testRefresh(inspector, panel);
({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI();
yield testRefresh(inspector, panel);
});
function* testRefresh(inspector, panel) {
info("Select a non animated node"); info("Select a non animated node");
yield selectNode(".still", inspector); yield selectNode(".still", inspector);
@ -19,14 +26,14 @@ add_task(function*() {
info("Select the animated node now"); info("Select the animated node now");
yield selectNode(".animated", inspector); yield selectNode(".animated", inspector);
ok(!panel.playerWidgets || !panel.playerWidgets.length, assertAnimationsDisplayed(panel, 0,
"The panel doesn't show the animation data while inactive"); "The panel doesn't show the animation data while inactive");
info("Switch to the animation panel"); info("Switch to the animation panel");
inspector.sidebar.select("animationinspector"); inspector.sidebar.select("animationinspector");
yield panel.once(panel.UI_UPDATED_EVENT); yield panel.once(panel.UI_UPDATED_EVENT);
is(panel.playerWidgets.length, 1, assertAnimationsDisplayed(panel, 1,
"The panel shows the animation data after selecting it"); "The panel shows the animation data after selecting it");
info("Switch again to the rule-view"); info("Switch again to the rule-view");
@ -35,13 +42,13 @@ add_task(function*() {
info("Select the non animated node again"); info("Select the non animated node again");
yield selectNode(".still", inspector); yield selectNode(".still", inspector);
is(panel.playerWidgets.length, 1, assertAnimationsDisplayed(panel, 1,
"The panel still shows the previous animation data since it is inactive"); "The panel still shows the previous animation data since it is inactive");
info("Switch to the animation panel again"); info("Switch to the animation panel again");
inspector.sidebar.select("animationinspector"); inspector.sidebar.select("animationinspector");
yield panel.once(panel.UI_UPDATED_EVENT); yield panel.once(panel.UI_UPDATED_EVENT);
ok(!panel.playerWidgets || !panel.playerWidgets.length, assertAnimationsDisplayed(panel, 0,
"The panel is now empty after refreshing"); "The panel is now empty after refreshing");
}); }

View File

@ -22,4 +22,16 @@ add_task(function*() {
is(widget.el.parentNode, panel.playersEl, is(widget.el.parentNode, panel.playersEl,
"The player widget has been appended to the panel"); "The player widget has been appended to the panel");
} }
info("Test again with the new UI, making sure the same number of " +
"animation timelines is created");
({inspector, panel, controller}) = yield closeAnimationInspectorAndRestartWithNewUI();
let timeline = panel.animationsTimelineComponent;
info("Selecting the test animated node again");
yield selectNode(".multi", inspector);
is(controller.animationPlayers.length,
timeline.animationsEl.querySelectorAll(".animation").length,
"As many timeline elements were created as there are playerFronts");
}); });

View File

@ -9,12 +9,18 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {inspector, panel} = yield openAnimationInspector();
let {inspector, panel} = yield openAnimationInspector();
yield testShowsAnimations(inspector, panel);
({inspector, panel}) = yield closeAnimationInspectorAndRestartWithNewUI();
yield testShowsAnimations(inspector, panel);
});
function* testShowsAnimations(inspector, panel) {
info("Select node .animated and check that the panel is not empty"); info("Select node .animated and check that the panel is not empty");
let node = yield getNodeFront(".animated", inspector); let node = yield getNodeFront(".animated", inspector);
yield selectNode(node, inspector); yield selectNode(node, inspector);
is(panel.playerWidgets.length, 1, assertAnimationsDisplayed(panel, 1);
"Exactly 1 player widget is shown for animated node"); }
});

View File

@ -9,14 +9,26 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {toolbox, inspector, panel} = yield openAnimationInspector();
let ui = yield openAnimationInspector();
yield testTargetNode(ui);
ui = yield closeAnimationInspectorAndRestartWithNewUI();
yield testTargetNode(ui, true);
});
function* testTargetNode({toolbox, inspector, panel}, isNewUI) {
info("Select the simple animated node"); info("Select the simple animated node");
yield selectNode(".animated", inspector); yield selectNode(".animated", inspector);
// Make sure to wait for the target-retrieved event if the nodeFront hasn't // Make sure to wait for the target-retrieved event if the nodeFront hasn't
// yet been retrieved by the TargetNodeComponent. // yet been retrieved by the TargetNodeComponent.
let targetNodeComponent = panel.playerWidgets[0].targetNodeComponent; let targetNodeComponent;
if (isNewUI) {
targetNodeComponent = panel.animationsTimelineComponent.targetNodes[0];
} else {
targetNodeComponent = panel.playerWidgets[0].targetNodeComponent;
}
if (!targetNodeComponent.nodeFront) { if (!targetNodeComponent.nodeFront) {
yield targetNodeComponent.once("target-retrieved"); yield targetNodeComponent.once("target-retrieved");
} }
@ -33,21 +45,29 @@ add_task(function*() {
ok(true, "The node-highlight event was fired"); ok(true, "The node-highlight event was fired");
is(targetNodeComponent.nodeFront, nodeFront, is(targetNodeComponent.nodeFront, nodeFront,
"The highlighted node is the one stored on the animation widget"); "The highlighted node is the one stored on the animation widget");
is(nodeFront.tagName, "DIV", "The highlighted node has the correct tagName"); is(nodeFront.tagName, "DIV",
is(nodeFront.attributes[0].name, "class", "The highlighted node has the correct attributes"); "The highlighted node has the correct tagName");
is(nodeFront.attributes[0].value, "ball animated", "The highlighted node has the correct class"); is(nodeFront.attributes[0].name, "class",
"The highlighted node has the correct attributes");
is(nodeFront.attributes[0].value, "ball animated",
"The highlighted node has the correct class");
info("Select the body node in order to have the list of all animations"); info("Select the body node in order to have the list of all animations");
yield selectNode("body", inspector); yield selectNode("body", inspector);
// Make sure to wait for the target-retrieved event if the nodeFront hasn't // Make sure to wait for the target-retrieved event if the nodeFront hasn't
// yet been retrieved by the TargetNodeComponent. // yet been retrieved by the TargetNodeComponent.
targetNodeComponent = panel.playerWidgets[0].targetNodeComponent; if (isNewUI) {
targetNodeComponent = panel.animationsTimelineComponent.targetNodes[0];
} else {
targetNodeComponent = panel.playerWidgets[0].targetNodeComponent;
}
if (!targetNodeComponent.nodeFront) { if (!targetNodeComponent.nodeFront) {
yield targetNodeComponent.once("target-retrieved"); yield targetNodeComponent.once("target-retrieved");
} }
info("Click on the first animation widget's selector icon and wait for the selection to change"); info("Click on the first animation widget's selector icon and wait for the " +
"selection to change");
let onSelection = inspector.selection.once("new-node-front"); let onSelection = inspector.selection.once("new-node-front");
let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT); let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
let selectIconEl = targetNodeComponent.selectNodeEl; let selectIconEl = targetNodeComponent.selectNodeEl;
@ -59,4 +79,4 @@ add_task(function*() {
"The selected node is the one stored on the animation widget"); "The selected node is the one stored on the animation widget");
yield onPanelUpdated; yield onPanelUpdated;
}); }

View File

@ -11,7 +11,7 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {inspector, panel} = yield openAnimationInspector(); let {panel} = yield openAnimationInspector();
info("Click the toggle button"); info("Click the toggle button");
yield panel.toggleAll(); yield panel.toggleAll();

View File

@ -9,7 +9,7 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {inspector, panel, window} = yield openAnimationInspector(); let {inspector, window} = yield openAnimationInspector();
let doc = window.document; let doc = window.document;
let toolbar = doc.querySelector("#toolbar"); let toolbar = doc.querySelector("#toolbar");

View File

@ -9,36 +9,64 @@
add_task(function*() { add_task(function*() {
yield addTab(TEST_URL_ROOT + "doc_simple_animation.html"); yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
let {panel, inspector} = yield openAnimationInspector();
let ui = yield openAnimationInspector();
yield testDataUpdates(ui);
info("Close the toolbox, reload the tab, and try again with the new UI");
ui = yield closeAnimationInspectorAndRestartWithNewUI(true);
yield testDataUpdates(ui, true);
});
function* testDataUpdates({panel, controller, inspector}, isNewUI=false) {
info("Select the test node"); info("Select the test node");
yield selectNode(".animated", inspector); yield selectNode(".animated", inspector);
info("Get the player widget"); let animation = controller.animationPlayers[0];
let widget = panel.playerWidgets[0]; yield setStyle(animation, "animationDuration", "5.5s", isNewUI);
yield setStyle(animation, "animationIterationCount", "300", isNewUI);
yield setStyle(animation, "animationDelay", "45s", isNewUI);
yield setStyle(widget, "animationDuration", "5.5s"); if (isNewUI) {
is(widget.metaDataComponent.durationValue.textContent, "5.50s", let animationsEl = panel.animationsTimelineComponent.animationsEl;
"The widget shows the new duration"); let timeBlockEl = animationsEl.querySelector(".time-block");
yield setStyle(widget, "animationIterationCount", "300"); // 45s delay + (300 * 5.5)s duration
is(widget.metaDataComponent.iterationValue.textContent, "300", let expectedTotalDuration = 1695 * 1000;
"The widget shows the new iteration count"); let timeRatio = expectedTotalDuration / timeBlockEl.offsetWidth;
yield setStyle(widget, "animationDelay", "45s"); // XXX: the nb and size of each iteration cannot be tested easily (displayed
is(widget.metaDataComponent.delayValue.textContent, "45s", // using a linear-gradient background and capped at 2px wide). They should
"The widget shows the new delay"); // be tested in bug 1173761.
}); let delayWidth = parseFloat(timeBlockEl.querySelector(".delay").style.width);
is(Math.round(delayWidth * timeRatio), 45 * 1000,
"The timeline has the right delay");
} else {
let widget = panel.playerWidgets[0];
is(widget.metaDataComponent.durationValue.textContent, "5.50s",
"The widget shows the new duration");
is(widget.metaDataComponent.iterationValue.textContent, "300",
"The widget shows the new iteration count");
is(widget.metaDataComponent.delayValue.textContent, "45s",
"The widget shows the new delay");
}
}
function* setStyle(widget, name, value) { function* setStyle(animation, name, value, isNewUI=false) {
info("Change the animation style via the content DOM. Setting " + info("Change the animation style via the content DOM. Setting " +
name + " to " + value); name + " to " + value);
let onAnimationChanged = once(animation, "changed");
yield executeInContent("devtools:test:setStyle", { yield executeInContent("devtools:test:setStyle", {
selector: ".animated", selector: ".animated",
propertyName: name, propertyName: name,
propertyValue: value propertyValue: value
}); });
yield onAnimationChanged;
info("Wait for the next state update"); // If this is the playerWidget-based UI, wait for the auto-refresh event too
yield onceNextPlayerRefresh(widget.player); // to make sure the UI has updated.
if (!isNewUI) {
yield once(animation, animation.AUTO_REFRESH_EVENT);
}
} }

View File

@ -9,7 +9,7 @@ const {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
const TargetFactory = devtools.TargetFactory; const TargetFactory = devtools.TargetFactory;
const {console} = Components.utils.import("resource://gre/modules/devtools/Console.jsm", {}); const {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
const {ViewHelpers} = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {}); const {ViewHelpers} = Cu.import("resource:///modules/devtools/ViewHelpers.jsm", {});
// All tests are asynchronous // All tests are asynchronous
@ -19,17 +19,20 @@ const TEST_URL_ROOT = "http://example.com/browser/browser/devtools/animationinsp
const ROOT_TEST_DIR = getRootDirectory(gTestPath); const ROOT_TEST_DIR = getRootDirectory(gTestPath);
const FRAME_SCRIPT_URL = ROOT_TEST_DIR + "doc_frame_script.js"; const FRAME_SCRIPT_URL = ROOT_TEST_DIR + "doc_frame_script.js";
const COMMON_FRAME_SCRIPT_URL = "chrome://browser/content/devtools/frame-script-utils.js"; const COMMON_FRAME_SCRIPT_URL = "chrome://browser/content/devtools/frame-script-utils.js";
const NEW_UI_PREF = "devtools.inspector.animationInspectorV3";
// Auto clean-up when a test ends // Auto clean-up when a test ends
registerCleanupFunction(function*() { registerCleanupFunction(function*() {
let target = TargetFactory.forTab(gBrowser.selectedTab); yield closeAnimationInspector();
yield gDevTools.closeToolbox(target);
while (gBrowser.tabs.length > 1) { while (gBrowser.tabs.length > 1) {
gBrowser.removeCurrentTab(); gBrowser.removeCurrentTab();
} }
}); });
// Make sure the new UI is off by default.
Services.prefs.setBoolPref(NEW_UI_PREF, false);
// Uncomment this pref to dump all devtools emitted events to the console. // Uncomment this pref to dump all devtools emitted events to the console.
// Services.prefs.setBoolPref("devtools.dump.emit", true); // Services.prefs.setBoolPref("devtools.dump.emit", true);
@ -45,6 +48,7 @@ registerCleanupFunction(() => gDevTools.testing = false);
registerCleanupFunction(() => { registerCleanupFunction(() => {
Services.prefs.clearUserPref("devtools.dump.emit"); Services.prefs.clearUserPref("devtools.dump.emit");
Services.prefs.clearUserPref("devtools.debugger.log"); Services.prefs.clearUserPref("devtools.debugger.log");
Services.prefs.clearUserPref(NEW_UI_PREF);
}); });
/** /**
@ -77,6 +81,13 @@ function addTab(url) {
return def.promise; return def.promise;
} }
/**
* Switch ON the new UI pref.
*/
function enableNewUI() {
Services.prefs.setBoolPref(NEW_UI_PREF, true);
}
/** /**
* Reload the current tab location. * Reload the current tab location.
*/ */
@ -119,6 +130,25 @@ let selectNode = Task.async(function*(data, inspector, reason="test") {
yield updated; yield updated;
}); });
/**
* Check if there are the expected number of animations being displayed in the
* panel right now.
* @param {AnimationsPanel} panel
* @param {Number} nbAnimations The expected number of animations.
* @param {String} msg An optional string to be used as the assertion message.
*/
function assertAnimationsDisplayed(panel, nbAnimations, msg="") {
let isNewUI = Services.prefs.getBoolPref(NEW_UI_PREF);
msg = msg || `There are ${nbAnimations} animations in the panel`;
if (isNewUI) {
is(panel.animationsTimelineComponent.animationsEl.childNodes.length,
nbAnimations, msg);
} else {
is(panel.playersEl.querySelectorAll(".player-widget").length,
nbAnimations, msg);
}
}
/** /**
* Takes an Inspector panel that was just created, and waits * Takes an Inspector panel that was just created, and waits
* for a "inspector-updated" event as well as the animation inspector * for a "inspector-updated" event as well as the animation inspector
@ -131,10 +161,9 @@ let waitForAnimationInspectorReady = Task.async(function*(inspector) {
let win = inspector.sidebar.getWindowForTab("animationinspector"); let win = inspector.sidebar.getWindowForTab("animationinspector");
let updated = inspector.once("inspector-updated"); let updated = inspector.once("inspector-updated");
// In e10s, if we wait for underlying toolbox actors to // In e10s, if we wait for underlying toolbox actors to load (by setting
// load (by setting gDevTools.testing to true), we miss the "animationinspector-ready" // gDevTools.testing to true), we miss the "animationinspector-ready" event on
// event on the sidebar, so check to see if the iframe // the sidebar, so check to see if the iframe is already loaded.
// is already loaded.
let tabReady = win.document.readyState === "complete" ? let tabReady = win.document.readyState === "complete" ?
promise.resolve() : promise.resolve() :
inspector.sidebar.once("animationinspector-ready"); inspector.sidebar.once("animationinspector-ready");
@ -145,7 +174,7 @@ let waitForAnimationInspectorReady = Task.async(function*(inspector) {
/** /**
* Open the toolbox, with the inspector tool visible and the animationinspector * Open the toolbox, with the inspector tool visible and the animationinspector
* sidebar selected. * sidebar selected.
* @return a promise that resolves when the inspector is ready * @return a promise that resolves when the inspector is ready.
*/ */
let openAnimationInspector = Task.async(function*() { let openAnimationInspector = Task.async(function*() {
let target = TargetFactory.forTab(gBrowser.selectedTab); let target = TargetFactory.forTab(gBrowser.selectedTab);
@ -185,6 +214,35 @@ let openAnimationInspector = Task.async(function*() {
}; };
}); });
/**
* Close the toolbox.
* @return a promise that resolves when the toolbox has closed.
*/
let closeAnimationInspector = Task.async(function*() {
let target = TargetFactory.forTab(gBrowser.selectedTab);
yield gDevTools.closeToolbox(target);
});
/**
* During the time period we migrate from the playerWidgets-based UI to the new
* AnimationTimeline UI, we'll want to run certain tests against both UI.
* This closes the toolbox, switch the new UI pref ON, and opens the toolbox
* again, with the animation inspector panel selected.
* @param {Boolean} reload Optionally reload the page after the toolbox was
* closed and before it is opened again.
* @return a promise that resolves when the animation inspector is ready.
*/
let closeAnimationInspectorAndRestartWithNewUI = Task.async(function*(reload) {
info("Close the toolbox and test again with the new UI");
yield closeAnimationInspector();
if (reload) {
yield reloadTab();
}
enableNewUI();
return yield openAnimationInspector();
});
/** /**
* Wait for the toolbox frame to receive focus after it loads * Wait for the toolbox frame to receive focus after it loads
* @param {Toolbox} toolbox * @param {Toolbox} toolbox
@ -214,7 +272,7 @@ function hasSideBarTab(inspector, id) {
* @param {Object} target An observable object that either supports on/off or * @param {Object} target An observable object that either supports on/off or
* addEventListener/removeEventListener * addEventListener/removeEventListener
* @param {String} eventName * @param {String} eventName
* @param {Boolean} useCapture Optional, for addEventListener/removeEventListener * @param {Boolean} useCapture Optional, for add/removeEventListener
* @return A promise that resolves when the event has been handled * @return A promise that resolves when the event has been handled
*/ */
function once(target, eventName, useCapture=false) { function once(target, eventName, useCapture=false) {
@ -278,9 +336,9 @@ function executeInContent(name, data={}, objects={}, expectResponse=true) {
mm.sendAsyncMessage(name, data, objects); mm.sendAsyncMessage(name, data, objects);
if (expectResponse) { if (expectResponse) {
return waitForContentMessage(name); return waitForContentMessage(name);
} else {
return promise.resolve();
} }
return promise.resolve();
} }
function onceNextPlayerRefresh(player) { function onceNextPlayerRefresh(player) {
@ -293,7 +351,9 @@ function onceNextPlayerRefresh(player) {
* Simulate a click on the playPause button of a playerWidget. * Simulate a click on the playPause button of a playerWidget.
*/ */
let togglePlayPauseButton = Task.async(function*(widget) { let togglePlayPauseButton = Task.async(function*(widget) {
let nextState = widget.player.state.playState === "running" ? "paused" : "running"; let nextState = widget.player.state.playState === "running"
? "paused"
: "running";
// Note that instead of simulating a real event here, the callback is just // Note that instead of simulating a real event here, the callback is just
// called. This is better because the callback returns a promise, so we know // called. This is better because the callback returns a promise, so we know
@ -344,7 +404,8 @@ let waitForStateCondition = Task.async(function*(player, conditionCheck, desc=""
* provided string. * provided string.
* @param {AnimationPlayerFront} player * @param {AnimationPlayerFront} player
* @param {String} playState The playState to expect. * @param {String} playState The playState to expect.
* @return {Promise} Resolves when the playState has changed to the expected value. * @return {Promise} Resolves when the playState has changed to the expected
* value.
*/ */
function waitForPlayState(player, playState) { function waitForPlayState(player, playState) {
return waitForStateCondition(player, state => { return waitForStateCondition(player, state => {

View File

@ -0,0 +1,135 @@
/* -*- 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/. */
"use strict";
// How many times, maximum, can we loop before we find the optimal time
// interval in the timeline graph.
const OPTIMAL_TIME_INTERVAL_MAX_ITERS = 100;
// Background time graduations should be multiple of this number of millis.
const TIME_INTERVAL_MULTIPLE = 10;
const TIME_INTERVAL_SCALES = 3;
// The default minimum spacing between time graduations in px.
const TIME_GRADUATION_MIN_SPACING = 10;
// RGB color for the time interval background.
const TIME_INTERVAL_COLOR = [128, 136, 144];
const TIME_INTERVAL_OPACITY_MIN = 32; // byte
const TIME_INTERVAL_OPACITY_ADD = 32; // byte
/**
* DOM node creation helper function.
* @param {Object} Options to customize the node to be created.
* - nodeType {String} Optional, defaults to "div",
* - attributes {Object} Optional attributes object like
* {attrName1:value1, attrName2: value2, ...}
* - parent {DOMNode} Mandatory node to append the newly created node to.
* - textContent {String} Optional text for the node.
* @return {DOMNode} The newly created node.
*/
function createNode(options) {
if (!options.parent) {
throw new Error("Missing parent DOMNode to create new node");
}
let type = options.nodeType || "div";
let node = options.parent.ownerDocument.createElement(type);
for (let name in options.attributes || {}) {
let value = options.attributes[name];
node.setAttribute(name, value);
}
if (options.textContent) {
node.textContent = options.textContent;
}
options.parent.appendChild(node);
return node;
}
exports.createNode = createNode;
/**
* Given a data-scale, draw the background for a graph (vertical lines) into a
* canvas and set that canvas as an image-element with an ID that can be used
* from CSS.
* @param {Document} document The document where the image-element should be set.
* @param {String} id The ID for the image-element.
* @param {Number} graphWidth The width of the graph.
* @param {Number} timeScale How many px is 1ms in the graph.
*/
function drawGraphElementBackground(document, id, graphWidth, timeScale) {
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
// Set the canvas width (as requested) and height (1px, repeated along the Y
// axis).
canvas.width = graphWidth;
canvas.height = 1;
// Create the image data array which will receive the pixels.
let imageData = ctx.createImageData(canvas.width, canvas.height);
let pixelArray = imageData.data;
let buf = new ArrayBuffer(pixelArray.length);
let view8bit = new Uint8ClampedArray(buf);
let view32bit = new Uint32Array(buf);
// Build new millisecond tick lines...
let [r, g, b] = TIME_INTERVAL_COLOR;
let alphaComponent = TIME_INTERVAL_OPACITY_MIN;
let interval = findOptimalTimeInterval(timeScale);
// Insert one pixel for each division on each scale.
for (let i = 1; i <= TIME_INTERVAL_SCALES; i++) {
let increment = interval * Math.pow(2, i);
for (let x = 0; x < canvas.width; x += increment) {
let position = x | 0;
view32bit[position] = (alphaComponent << 24) | (b << 16) | (g << 8) | r;
}
alphaComponent += TIME_INTERVAL_OPACITY_ADD;
}
// Flush the image data and cache the waterfall background.
pixelArray.set(view8bit);
ctx.putImageData(imageData, 0, 0);
document.mozSetImageElement(id, canvas);
}
exports.drawGraphElementBackground = drawGraphElementBackground;
/**
* Find the optimal interval between time graduations in the animation timeline
* graph based on a time scale and a minimum spacing.
* @param {Number} timeScale How many px is 1ms in the graph.
* @param {Number} minSpacing The minimum spacing between 2 graduations,
* defaults to TIME_GRADUATION_MIN_SPACING.
* @return {Number} The optional interval, in pixels.
*/
function findOptimalTimeInterval(timeScale,
minSpacing=TIME_GRADUATION_MIN_SPACING) {
let timingStep = TIME_INTERVAL_MULTIPLE;
let maxIters = OPTIMAL_TIME_INTERVAL_MAX_ITERS;
let numIters = 0;
if (timeScale > minSpacing) {
return timeScale;
}
while (true) {
let scaledStep = timeScale * timingStep;
if (++numIters > maxIters) {
return scaledStep;
}
if (scaledStep < minSpacing) {
timingStep *= 2;
continue;
}
return scaledStep;
}
}
exports.findOptimalTimeInterval = findOptimalTimeInterval;

View File

@ -52,3 +52,9 @@ player.timeLabel=%Ss
# drop-down list items that can be used to change the rate at which the # drop-down list items that can be used to change the rate at which the
# animation runs (1x being the default, 2x being twice as fast). # animation runs (1x being the default, 2x being twice as fast).
player.playbackRateLabel=%Sx player.playbackRateLabel=%Sx
# LOCALIZATION NOTE (timeline.timeGraduationLabel):
# This string is displayed at the top of the animation panel, next to each time
# graduation, to indicate what duration (in milliseconds) this graduation
# corresponds to.
timeline.timeGraduationLabel=%Sms

View File

@ -1,3 +1,17 @@
/* 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/. */
/* Animation-inspector specific theme variables */
.theme-dark {
--even-animation-timeline-background-color: rgba(255,255,255,0.03);
}
.theme-light {
--even-animation-timeline-background-color: rgba(128,128,128,0.03);
}
html { html {
height: 100%; height: 100%;
} }
@ -32,6 +46,13 @@ body {
min-height: 20px; min-height: 20px;
} }
/* The main animations container */
#players {
height: calc(100% - 20px);
overflow: auto;
}
/* The error message, shown when an invalid/unanimated element is selected */ /* The error message, shown when an invalid/unanimated element is selected */
#error-message { #error-message {
@ -44,12 +65,6 @@ body {
display: none; display: none;
} }
/* The animation players container */
#players {
flex: 1;
overflow: auto;
}
/* Element picker and toggle-all buttons */ /* Element picker and toggle-all buttons */
@ -99,6 +114,156 @@ body {
} }
} }
/* Animation timeline component */
.animation-timeline {
height: 100%;
overflow: hidden;
/* The timeline gets its background-image from a canvas element created in
/browser/devtools/animationinspector/utils.js drawGraphElementBackground
thanks to document.mozSetImageElement("time-graduations", canvas)
This is done so that the background can be built dynamically from script */
background-image: -moz-element(#time-graduations);
background-repeat: repeat-y;
/* The animations are drawn 150px from the left edge so that animated nodes
can be displayed in a sidebar */
background-position: 150px 0;
display: flex;
flex-direction: column;
}
.animation-timeline .time-header {
margin-left: 150px;
height: 20px;
overflow: hidden;
position: relative;
border-bottom: 1px solid var(--theme-splitter-color);
}
.animation-timeline .time-header .time-tick {
position: absolute;
top: 3px;
}
.animation-timeline .animations {
width: 100%;
overflow-y: auto;
overflow-x: hidden;
margin: 0;
padding: 0;
list-style-type: none;
}
/* Animation block widgets */
.animation-timeline .animation {
margin: 4px 0;
height: 20px;
position: relative;
}
.animation-timeline .animation:nth-child(2n) {
background-color: var(--even-animation-timeline-background-color);
}
.animation-timeline .animation .target {
width: 150px;
overflow: hidden;
height: 100%;
}
.animation-timeline .animation-target {
background-color: transparent;
}
.animation-timeline .animation .time-block {
position: absolute;
top: 0;
left: 150px;
right: 0;
height: 100%;
}
/* Animation iterations */
.animation-timeline .animation .iterations {
position: relative;
height: 100%;
border: 1px solid var(--theme-highlight-lightorange);
box-sizing: border-box;
background: var(--theme-contrast-background);
/* Iterations are displayed with a repeating linear-gradient which size is
dynamically changed from JS */
background-image:
linear-gradient(to right,
var(--theme-highlight-lightorange) 0,
var(--theme-highlight-lightorange) 1px,
transparent 1px,
transparent 2px);
background-repeat: repeat-x;
background-position: -1px 0;
}
.animation-timeline .animation .iterations.infinite {
border-right-width: 0;
}
.animation-timeline .animation .iterations.infinite::before,
.animation-timeline .animation .iterations.infinite::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-right: 4px solid var(--theme-body-background);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
}
.animation-timeline .animation .iterations.infinite::after {
bottom: 0;
top: unset;
}
.animation-timeline .animation .animation-title {
height: 1.5em;
width: 100%;
box-sizing: border-box;
overflow: hidden;
}
.animation-timeline .animation .delay {
position: absolute;
top: 0;
height: 100%;
background-image: linear-gradient(to bottom,
transparent,
transparent 9px,
var(--theme-highlight-lightorange) 9px,
var(--theme-highlight-lightorange) 11px,
transparent 11px,
transparent);
}
.animation-timeline .animation .delay::before {
position: absolute;
content: "";
left: 0;
width: 2px;
height: 8px;
top: 50%;
margin-top: -4px;
background: var(--theme-highlight-lightorange);
}
.animation-timeline .animation .name {
position: absolute;
z-index: 1;
padding: 2px;
white-space: nowrap;
}
/* Animation target node gutter, contains a preview of the dom node */ /* Animation target node gutter, contains a preview of the dom node */
.animation-target { .animation-target {
@ -253,4 +418,4 @@ body {
width: 50px; width: 50px;
border-left: 1px solid var(--theme-splitter-color); border-left: 1px solid var(--theme-splitter-color);
background: var(--theme-toolbar-background); background: var(--theme-toolbar-background);
} }

View File

@ -5,7 +5,8 @@
"use strict"; "use strict";
/** /**
* Set of actors that expose the Web Animations API to devtools protocol clients. * 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 * The |Animations| actor is the main entry point. It is used to discover
* animation players on given nodes. * animation players on given nodes.
@ -29,11 +30,15 @@ const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
const {Task} = Cu.import("resource://gre/modules/Task.jsm", {}); const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
const {setInterval, clearInterval} = require("sdk/timers"); const {setInterval, clearInterval} = require("sdk/timers");
const protocol = require("devtools/server/protocol"); const protocol = require("devtools/server/protocol");
const {ActorClass, Actor, FrontClass, Front, Arg, method, RetVal, types} = protocol; const {ActorClass, Actor, FrontClass, Front,
Arg, method, RetVal, types} = protocol;
// Make sure the nodeActor type is know here.
const {NodeActor} = require("devtools/server/actors/inspector"); const {NodeActor} = require("devtools/server/actors/inspector");
const events = require("sdk/event/core"); const events = require("sdk/event/core");
const PLAYER_DEFAULT_AUTO_REFRESH_TIMEOUT = 500; // ms // How long (in ms) should we wait before polling again the state of an
// animationPlayer.
const PLAYER_DEFAULT_AUTO_REFRESH_TIMEOUT = 500;
/** /**
* The AnimationPlayerActor provides information about a given animation: its * The AnimationPlayerActor provides information about a given animation: its
@ -47,6 +52,13 @@ const PLAYER_DEFAULT_AUTO_REFRESH_TIMEOUT = 500; // ms
let AnimationPlayerActor = ActorClass({ let AnimationPlayerActor = ActorClass({
typeName: "animationplayer", typeName: "animationplayer",
events: {
"changed": {
type: "changed",
state: Arg(0, "json")
}
},
/** /**
* @param {AnimationsActor} The main AnimationsActor instance * @param {AnimationsActor} The main AnimationsActor instance
* @param {AnimationPlayer} The player object returned by getAnimationPlayers * @param {AnimationPlayer} The player object returned by getAnimationPlayers
@ -58,14 +70,29 @@ let AnimationPlayerActor = ActorClass({
initialize: function(animationsActor, player, playerIndex) { initialize: function(animationsActor, player, playerIndex) {
Actor.prototype.initialize.call(this, animationsActor.conn); Actor.prototype.initialize.call(this, animationsActor.conn);
this.onAnimationMutation = this.onAnimationMutation.bind(this);
this.tabActor = animationsActor.tabActor;
this.player = player; this.player = player;
this.node = player.effect.target; this.node = player.effect.target;
this.playerIndex = playerIndex; this.playerIndex = playerIndex;
this.styles = this.node.ownerDocument.defaultView.getComputedStyle(this.node);
let win = this.node.ownerDocument.defaultView;
this.styles = win.getComputedStyle(this.node);
// Listen to animation mutations on the node to alert the front when the
// current animation changes.
this.observer = new win.MutationObserver(this.onAnimationMutation);
this.observer.observe(this.node, {animations: true});
}, },
destroy: function() { destroy: function() {
this.player = this.node = this.styles = null; // 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.tabActor = this.player = this.node = this.styles = this.observer = null;
Actor.prototype.destroy.call(this); Actor.prototype.destroy.call(this);
}, },
@ -95,14 +122,14 @@ let AnimationPlayerActor = ActorClass({
*/ */
getPlayerIndex: function() { getPlayerIndex: function() {
let names = this.styles.animationName; let names = this.styles.animationName;
if (names === "none") {
names = this.styles.transitionProperty;
}
// If no names are found, then it's probably a transition, in which case we // If we still don't have a name, let's fall back to the provided index
// can't find the actual index, so just trust the playerIndex passed by // which may, by now, be wrong, but it's the best we can do until the waapi
// the AnimationsActor at initialization time. // gives us a way to get duration, delay, ... directly.
// Note that this may be incorrect if by the time the AnimationPlayerActor if (!names || names === "none") {
// is initialized, one of the transitions has ended, but it's the best we
// can do for now.
if (!names) {
return this.playerIndex; return this.playerIndex;
} }
@ -114,7 +141,7 @@ let AnimationPlayerActor = ActorClass({
// If there are several names, retrieve the index of the animation name in // If there are several names, retrieve the index of the animation name in
// the list. // the list.
names = names.split(",").map(n => n.trim()); names = names.split(",").map(n => n.trim());
for (let i = 0; i < names.length; i ++) { for (let i = 0; i < names.length; i++) {
if (names[i] === this.player.effect.name) { if (names[i] === this.player.effect.name) {
return i; return i;
} }
@ -244,6 +271,27 @@ let AnimationPlayerActor = ActorClass({
} }
}), }),
/**
* Executed when the current animation changes, used to emit the new state
* the the front.
*/
onAnimationMutation: function(mutations) {
let hasChanged = false;
for (let {changedAnimations} of mutations) {
if (!changedAnimations.length) {
return;
}
if (changedAnimations.some(animation => animation === this.player)) {
hasChanged = true;
break;
}
}
if (hasChanged) {
events.emit(this, "changed", this.getCurrentState());
}
},
/** /**
* Pause the player. * Pause the player.
*/ */
@ -348,9 +396,18 @@ let AnimationPlayerFront = FrontClass(AnimationPlayerActor, {
delay: this._form.delay, delay: this._form.delay,
iterationCount: this._form.iterationCount, iterationCount: this._form.iterationCount,
isRunningOnCompositor: this._form.isRunningOnCompositor isRunningOnCompositor: this._form.isRunningOnCompositor
} };
}, },
/**
* Executed when the AnimationPlayerActor emits a "changed" event. Used to
* update the local knowledge of the state.
*/
onChanged: protocol.preEvent("changed", function(partialState) {
let {state} = this.reconstructState(partialState);
this.state = state;
}),
// About auto-refresh: // About auto-refresh:
// //
// The AnimationPlayerFront is capable of automatically refreshing its state // The AnimationPlayerFront is capable of automatically refreshing its state
@ -416,19 +473,28 @@ let AnimationPlayerFront = FrontClass(AnimationPlayerActor, {
*/ */
getCurrentState: protocol.custom(function() { getCurrentState: protocol.custom(function() {
this.currentStateHasChanged = false; this.currentStateHasChanged = false;
return this._getCurrentState().then(data => { return this._getCurrentState().then(partialData => {
for (let key in this.state) { let {state, hasChanged} = this.reconstructState(partialData);
if (typeof data[key] === "undefined") { this.currentStateHasChanged = hasChanged;
data[key] = this.state[key]; return state;
} else if (data[key] !== this.state[key]) {
this.currentStateHasChanged = true;
}
}
return data;
}); });
}, { }, {
impl: "_getCurrentState" impl: "_getCurrentState"
}), }),
reconstructState: function(data) {
let hasChanged = false;
for (let key in this.state) {
if (typeof data[key] === "undefined") {
data[key] = this.state[key];
} else if (data[key] !== this.state[key]) {
hasChanged = true;
}
}
return {state: data, hasChanged};
}
}); });
/** /**
@ -449,7 +515,7 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({
typeName: "animations", typeName: "animations",
events: { events: {
"mutations" : { "mutations": {
type: "mutations", type: "mutations",
changes: Arg(0, "array:animationMutationChange") changes: Arg(0, "array:animationMutationChange")
} }
@ -500,7 +566,7 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({
// No care is taken here to destroy the previously stored actors because it // No care is taken here to destroy the previously stored actors because it
// is assumed that the client is responsible for lifetimes of actors. // is assumed that the client is responsible for lifetimes of actors.
this.actors = []; this.actors = [];
for (let i = 0; i < animations.length; i ++) { for (let i = 0; i < animations.length; i++) {
// XXX: for now the index is passed along as the AnimationPlayerActor uses // XXX: for now the index is passed along as the AnimationPlayerActor uses
// it to retrieve animation information from CSS. // it to retrieve animation information from CSS.
let actor = AnimationPlayerActor(this, animations[i], i); let actor = AnimationPlayerActor(this, animations[i], i);
@ -532,7 +598,7 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({
onAnimationMutation: function(mutations) { onAnimationMutation: function(mutations) {
let eventData = []; let eventData = [];
for (let {addedAnimations, changedAnimations, removedAnimations} of mutations) { for (let {addedAnimations, removedAnimations} of mutations) {
for (let player of removedAnimations) { for (let player of removedAnimations) {
// Note that animations are reported as removed either when they are // Note that animations are reported as removed either when they are
// actually removed from the node (e.g. css class removed) or when they // actually removed from the node (e.g. css class removed) or when they
@ -588,9 +654,9 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({
}, },
/** /**
* After the client has called getAnimationPlayersForNode for a given DOM node, * After the client has called getAnimationPlayersForNode for a given DOM
* the actor starts sending animation mutations for this node. If the client * node, the actor starts sending animation mutations for this node. If the
* doesn't want this to happen anymore, it should call this method. * client doesn't want this to happen anymore, it should call this method.
*/ */
stopAnimationPlayerUpdates: method(function() { stopAnimationPlayerUpdates: method(function() {
if (this.observer && !Cu.isDeadWrapper(this.observer)) { if (this.observer && !Cu.isDeadWrapper(this.observer)) {
@ -666,7 +732,7 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({
/** /**
* Play all animations in the current tabActor's frames. * Play all animations in the current tabActor's frames.
* This method only returns when the animations have left their pending states. * This method only returns when animations have left their pending states.
*/ */
playAll: method(function() { playAll: method(function() {
let readyPromises = []; let readyPromises = [];
@ -687,9 +753,8 @@ let AnimationsActor = exports.AnimationsActor = ActorClass({
toggleAll: method(function() { toggleAll: method(function() {
if (this.allAnimationsPaused) { if (this.allAnimationsPaused) {
return this.playAll(); return this.playAll();
} else {
return this.pauseAll();
} }
return this.pauseAll();
}, { }, {
request: {}, request: {},
response: {} response: {}