2015-05-04 11:55:51 +00:00
|
|
|
/* -*- 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";
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
const {Cu} = require("chrome");
|
2015-10-13 23:18:43 +00:00
|
|
|
Cu.import("resource://devtools/client/shared/widgets/ViewHelpers.jsm");
|
2015-06-15 10:03:54 +00:00
|
|
|
const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
|
2015-06-11 13:45:57 +00:00
|
|
|
const {
|
|
|
|
createNode,
|
|
|
|
drawGraphElementBackground,
|
2015-10-09 08:44:53 +00:00
|
|
|
findOptimalTimeInterval,
|
|
|
|
TargetNodeHighlighter
|
2015-09-21 17:04:18 +00:00
|
|
|
} = require("devtools/client/animationinspector/utils");
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
const STRINGS_URI = "chrome://browser/locale/devtools/animationinspector.properties";
|
|
|
|
const L10N = new ViewHelpers.L10N(STRINGS_URI);
|
2015-06-11 13:45:57 +00:00
|
|
|
const MILLIS_TIME_FORMAT_MAX_DURATION = 4000;
|
2015-08-27 14:48:37 +00:00
|
|
|
// The minimum spacing between 2 time graduation headers in the timeline (px).
|
2015-06-11 13:45:57 +00:00
|
|
|
const TIME_GRADUATION_MIN_SPACING = 40;
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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.
|
2015-06-11 13:45:57 +00:00
|
|
|
* @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">
|
2015-05-04 11:55:51 +00:00
|
|
|
*/
|
2015-06-11 13:45:57 +00:00
|
|
|
function AnimationTargetNode(inspector, options={}) {
|
2015-05-04 11:55:51 +00:00
|
|
|
this.inspector = inspector;
|
2015-06-11 13:45:57 +00:00
|
|
|
this.options = options;
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
this.onPreviewMouseOver = this.onPreviewMouseOver.bind(this);
|
|
|
|
this.onPreviewMouseOut = this.onPreviewMouseOut.bind(this);
|
|
|
|
this.onSelectNodeClick = this.onSelectNodeClick.bind(this);
|
|
|
|
this.onMarkupMutations = this.onMarkupMutations.bind(this);
|
2015-10-09 08:44:53 +00:00
|
|
|
this.onHighlightNodeClick = this.onHighlightNodeClick.bind(this);
|
|
|
|
this.onTargetHighlighterLocked = this.onTargetHighlighterLocked.bind(this);
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
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.
|
2015-10-09 08:44:53 +00:00
|
|
|
this.highlightNodeEl = createNode({
|
2015-05-04 11:55:51 +00:00
|
|
|
parent: this.el,
|
|
|
|
nodeType: "span",
|
|
|
|
attributes: {
|
2015-10-09 08:44:53 +00:00
|
|
|
"class": "node-highlighter",
|
|
|
|
"title": L10N.getStr("node.highlightNodeLabel")
|
2015-05-04 11:55:51 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Wrapper used for mouseover/out event handling.
|
|
|
|
this.previewEl = createNode({
|
|
|
|
parent: this.el,
|
2015-10-09 08:44:53 +00:00
|
|
|
nodeType: "span",
|
|
|
|
attributes: {
|
|
|
|
"title": L10N.getStr("node.selectNodeLabel")
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
});
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
if (!this.options.compact) {
|
|
|
|
this.previewEl.appendChild(document.createTextNode("<"));
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
// 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"
|
|
|
|
});
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
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: "#"
|
|
|
|
});
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
createNode({
|
|
|
|
parent: this.idEl,
|
|
|
|
nodeType: "span",
|
|
|
|
attributes: {
|
|
|
|
"class": "attribute-value theme-fg-color6"
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
if (!this.options.compact) {
|
|
|
|
this.idEl.appendChild(document.createTextNode("\""));
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
// Class attribute container.
|
|
|
|
this.classEl = createNode({
|
|
|
|
parent: this.previewEl,
|
|
|
|
nodeType: "span"
|
|
|
|
});
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
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: "."
|
|
|
|
});
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
createNode({
|
|
|
|
parent: this.classEl,
|
|
|
|
nodeType: "span",
|
|
|
|
attributes: {
|
|
|
|
"class": "attribute-value theme-fg-color6"
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
if (!this.options.compact) {
|
|
|
|
this.classEl.appendChild(document.createTextNode("\""));
|
|
|
|
this.previewEl.appendChild(document.createTextNode(">"));
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
// Init events for highlighting and selecting the node.
|
|
|
|
this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver);
|
|
|
|
this.previewEl.addEventListener("mouseout", this.onPreviewMouseOut);
|
2015-10-09 08:44:53 +00:00
|
|
|
this.previewEl.addEventListener("click", this.onSelectNodeClick);
|
|
|
|
this.highlightNodeEl.addEventListener("click", this.onHighlightNodeClick);
|
2015-05-04 11:55:51 +00:00
|
|
|
|
|
|
|
// Start to listen for markupmutation events.
|
|
|
|
this.inspector.on("markupmutation", this.onMarkupMutations);
|
2015-10-09 08:44:53 +00:00
|
|
|
|
|
|
|
// Listen to the target node highlighter.
|
|
|
|
TargetNodeHighlighter.on("highlighted", this.onTargetHighlighterLocked);
|
2015-05-04 11:55:51 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
destroy: function() {
|
2015-10-09 08:44:53 +00:00
|
|
|
TargetNodeHighlighter.unhighlight().catch(e => console.error(e));
|
|
|
|
|
|
|
|
TargetNodeHighlighter.off("highlighted", this.onTargetHighlighterLocked);
|
2015-05-04 11:55:51 +00:00
|
|
|
this.inspector.off("markupmutation", this.onMarkupMutations);
|
|
|
|
this.previewEl.removeEventListener("mouseover", this.onPreviewMouseOver);
|
|
|
|
this.previewEl.removeEventListener("mouseout", this.onPreviewMouseOut);
|
2015-10-09 08:44:53 +00:00
|
|
|
this.previewEl.removeEventListener("click", this.onSelectNodeClick);
|
|
|
|
this.highlightNodeEl.removeEventListener("click", this.onHighlightNodeClick);
|
|
|
|
|
2015-05-04 11:55:51 +00:00
|
|
|
this.el.remove();
|
|
|
|
this.el = this.tagNameEl = this.idEl = this.classEl = null;
|
2015-10-09 08:44:53 +00:00
|
|
|
this.highlightNodeEl = this.previewEl = null;
|
2015-05-04 11:55:51 +00:00
|
|
|
this.nodeFront = this.inspector = this.playerFront = null;
|
|
|
|
},
|
|
|
|
|
2015-10-09 08:44:53 +00:00
|
|
|
get highlighterUtils() {
|
|
|
|
return this.inspector.toolbox.highlighterUtils;
|
|
|
|
},
|
|
|
|
|
2015-05-04 11:55:51 +00:00
|
|
|
onPreviewMouseOver: function() {
|
|
|
|
if (!this.nodeFront) {
|
|
|
|
return;
|
|
|
|
}
|
2015-10-09 08:44:53 +00:00
|
|
|
this.highlighterUtils.highlightNodeFront(this.nodeFront);
|
2015-05-04 11:55:51 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
onPreviewMouseOut: function() {
|
2015-10-09 08:44:53 +00:00
|
|
|
if (!this.nodeFront) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.highlighterUtils.unhighlight();
|
2015-05-04 11:55:51 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
onSelectNodeClick: function() {
|
|
|
|
if (!this.nodeFront) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.inspector.selection.setNodeFront(this.nodeFront, "animationinspector");
|
|
|
|
},
|
|
|
|
|
2015-10-09 08:44:53 +00:00
|
|
|
onHighlightNodeClick: function() {
|
|
|
|
let classList = this.highlightNodeEl.classList;
|
|
|
|
|
|
|
|
let isHighlighted = classList.contains("selected");
|
|
|
|
if (isHighlighted) {
|
|
|
|
classList.remove("selected");
|
|
|
|
TargetNodeHighlighter.unhighlight().then(() => {
|
|
|
|
this.emit("target-highlighter-unlocked");
|
|
|
|
}, e => console.error(e));
|
|
|
|
} else {
|
|
|
|
classList.add("selected");
|
|
|
|
TargetNodeHighlighter.highlight(this).then(() => {
|
|
|
|
this.emit("target-highlighter-locked");
|
|
|
|
}, e => console.error(e));
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
onTargetHighlighterLocked: function(e, animationTargetNode) {
|
|
|
|
if (animationTargetNode !== this) {
|
|
|
|
this.highlightNodeEl.classList.remove("selected");
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
2015-05-04 11:55:51 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
render: Task.async(function*(playerFront) {
|
2015-05-04 11:55:51 +00:00
|
|
|
this.playerFront = playerFront;
|
2015-06-11 13:45:57 +00:00
|
|
|
this.nodeFront = undefined;
|
|
|
|
|
|
|
|
try {
|
|
|
|
this.nodeFront = yield this.inspector.walker.getNodeFromActor(
|
|
|
|
playerFront.actorID, ["node"]);
|
|
|
|
} catch (e) {
|
|
|
|
if (!this.el) {
|
2015-10-09 08:44:53 +00:00
|
|
|
// The panel was destroyed in the meantime. Just log a warning.
|
2015-06-11 13:45:57 +00:00
|
|
|
console.warn("Cound't retrieve the animation target node, widget " +
|
|
|
|
"destroyed");
|
2015-10-09 08:44:53 +00:00
|
|
|
} else {
|
|
|
|
// This was an unexpected error, log it.
|
|
|
|
console.error(e);
|
2015-05-04 11:55:51 +00:00
|
|
|
}
|
2015-06-11 13:45:57 +00:00
|
|
|
return;
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
if (!this.nodeFront || !this.el) {
|
|
|
|
return;
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
let {tagName, attributes} = this.nodeFront;
|
2015-05-04 11:55:51 +00:00
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
this.tagNameEl.textContent = tagName.toLowerCase();
|
2015-05-04 11:55:51 +00:00
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
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";
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
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(".");
|
2015-05-04 11:55:51 +00:00
|
|
|
}
|
2015-06-11 13:45:57 +00:00
|
|
|
|
|
|
|
this.classEl.querySelector(".attribute-value").textContent = value;
|
|
|
|
this.classEl.style.display = "inline";
|
|
|
|
} else {
|
|
|
|
this.classEl.style.display = "none";
|
|
|
|
}
|
|
|
|
|
|
|
|
this.emit("target-retrieved");
|
|
|
|
})
|
2015-05-04 11:55:51 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
2015-06-11 13:45:57 +00:00
|
|
|
* 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.
|
2015-05-04 11:55:51 +00:00
|
|
|
*/
|
2015-09-15 18:19:45 +00:00
|
|
|
var TimeScale = {
|
2015-06-11 13:45:57 +00:00
|
|
|
minStartTime: Infinity,
|
|
|
|
maxEndTime: 0,
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add a new animation to time scale.
|
|
|
|
* @param {Object} state A PlayerFront.state object.
|
|
|
|
*/
|
2015-08-27 14:59:16 +00:00
|
|
|
addAnimation: function(state) {
|
2015-09-18 07:28:14 +00:00
|
|
|
let {previousStartTime, delay, duration,
|
|
|
|
iterationCount, playbackRate} = state;
|
2015-08-27 14:59:16 +00:00
|
|
|
|
2015-09-04 15:43:41 +00:00
|
|
|
// Negative-delayed animations have their startTimes set such that we would
|
|
|
|
// be displaying the delay outside the time window if we didn't take it into
|
|
|
|
// account here.
|
|
|
|
let relevantDelay = delay < 0 ? delay / playbackRate : 0;
|
2015-09-18 07:28:14 +00:00
|
|
|
previousStartTime = previousStartTime || 0;
|
2015-09-04 15:43:41 +00:00
|
|
|
|
2015-09-18 07:28:14 +00:00
|
|
|
this.minStartTime = Math.min(this.minStartTime,
|
|
|
|
previousStartTime + relevantDelay);
|
2015-08-27 14:59:16 +00:00
|
|
|
let length = (delay / playbackRate) +
|
|
|
|
((duration / playbackRate) *
|
|
|
|
(!iterationCount ? 1 : iterationCount));
|
2015-09-18 07:28:14 +00:00
|
|
|
this.maxEndTime = Math.max(this.maxEndTime, previousStartTime + length);
|
2015-06-11 13:45:57 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
2015-05-04 11:55:51 +00:00
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
// 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));
|
|
|
|
}
|
2015-05-04 11:55:51 +00:00
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
// Otherwise format in seconds.
|
|
|
|
return L10N.getFormatStr("player.timeLabel", (time / 1000).toFixed(1));
|
2015-05-04 11:55:51 +00:00
|
|
|
}
|
2015-06-11 13:45:57 +00:00
|
|
|
};
|
2015-05-04 11:55:51 +00:00
|
|
|
|
2015-06-15 10:03:54 +00:00
|
|
|
exports.TimeScale = TimeScale;
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
/**
|
|
|
|
* 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.
|
2015-08-21 13:23:17 +00:00
|
|
|
* The current time play head can be moved by clicking/dragging in the header.
|
2015-09-16 15:00:07 +00:00
|
|
|
* when this happens, the component emits "current-data-changed" events with the
|
|
|
|
* new time and state of the timeline.
|
2015-08-21 13:23:17 +00:00
|
|
|
*
|
|
|
|
* @param {InspectorPanel} inspector.
|
2015-06-11 13:45:57 +00:00
|
|
|
*/
|
|
|
|
function AnimationsTimeline(inspector) {
|
|
|
|
this.animations = [];
|
|
|
|
this.targetNodes = [];
|
2015-10-16 20:35:28 +00:00
|
|
|
this.timeBlocks = [];
|
2015-06-11 13:45:57 +00:00
|
|
|
this.inspector = inspector;
|
|
|
|
this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this);
|
2015-10-15 08:14:35 +00:00
|
|
|
this.onScrubberMouseDown = this.onScrubberMouseDown.bind(this);
|
|
|
|
this.onScrubberMouseUp = this.onScrubberMouseUp.bind(this);
|
|
|
|
this.onScrubberMouseOut = this.onScrubberMouseOut.bind(this);
|
|
|
|
this.onScrubberMouseMove = this.onScrubberMouseMove.bind(this);
|
2015-08-21 13:23:17 +00:00
|
|
|
EventEmitter.decorate(this);
|
2015-05-04 11:55:51 +00:00
|
|
|
}
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
exports.AnimationsTimeline = AnimationsTimeline;
|
|
|
|
|
|
|
|
AnimationsTimeline.prototype = {
|
|
|
|
init: function(containerEl) {
|
|
|
|
this.win = containerEl.ownerDocument.defaultView;
|
|
|
|
|
|
|
|
this.rootWrapperEl = createNode({
|
|
|
|
parent: containerEl,
|
|
|
|
attributes: {
|
|
|
|
"class": "animation-timeline"
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2015-08-21 13:23:17 +00:00
|
|
|
this.scrubberEl = createNode({
|
|
|
|
parent: this.rootWrapperEl,
|
|
|
|
attributes: {
|
|
|
|
"class": "scrubber"
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2015-10-15 08:14:35 +00:00
|
|
|
this.scrubberHandleEl = createNode({
|
|
|
|
parent: this.scrubberEl,
|
|
|
|
attributes: {
|
|
|
|
"class": "scrubber-handle"
|
|
|
|
}
|
|
|
|
});
|
|
|
|
this.scrubberHandleEl.addEventListener("mousedown", this.onScrubberMouseDown);
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
this.timeHeaderEl = createNode({
|
|
|
|
parent: this.rootWrapperEl,
|
|
|
|
attributes: {
|
|
|
|
"class": "time-header"
|
|
|
|
}
|
|
|
|
});
|
2015-10-15 08:14:35 +00:00
|
|
|
this.timeHeaderEl.addEventListener("mousedown", this.onScrubberMouseDown);
|
2015-06-11 13:45:57 +00:00
|
|
|
|
|
|
|
this.animationsEl = createNode({
|
|
|
|
parent: this.rootWrapperEl,
|
|
|
|
nodeType: "ul",
|
|
|
|
attributes: {
|
|
|
|
"class": "animations"
|
|
|
|
}
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
destroy: function() {
|
2015-08-27 14:59:16 +00:00
|
|
|
this.stopAnimatingScrubber();
|
2015-06-11 13:45:57 +00:00
|
|
|
this.unrender();
|
|
|
|
|
2015-08-21 13:23:17 +00:00
|
|
|
this.timeHeaderEl.removeEventListener("mousedown",
|
2015-10-15 08:14:35 +00:00
|
|
|
this.onScrubberMouseDown);
|
|
|
|
this.scrubberHandleEl.removeEventListener("mousedown",
|
|
|
|
this.onScrubberMouseDown);
|
2015-08-21 13:23:17 +00:00
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
this.rootWrapperEl.remove();
|
|
|
|
this.animations = [];
|
|
|
|
|
|
|
|
this.rootWrapperEl = null;
|
|
|
|
this.timeHeaderEl = null;
|
|
|
|
this.animationsEl = null;
|
2015-08-21 13:23:17 +00:00
|
|
|
this.scrubberEl = null;
|
2015-10-15 08:14:35 +00:00
|
|
|
this.scrubberHandleEl = null;
|
2015-06-11 13:45:57 +00:00
|
|
|
this.win = null;
|
|
|
|
this.inspector = null;
|
|
|
|
},
|
|
|
|
destroyTargetNodes: function() {
|
|
|
|
for (let targetNode of this.targetNodes) {
|
|
|
|
targetNode.destroy();
|
|
|
|
}
|
|
|
|
this.targetNodes = [];
|
|
|
|
},
|
2015-10-16 20:35:28 +00:00
|
|
|
destroyTimeBlocks: function() {
|
|
|
|
for (let timeBlock of this.timeBlocks) {
|
|
|
|
timeBlock.destroy();
|
|
|
|
}
|
|
|
|
this.timeBlocks = [];
|
|
|
|
},
|
2015-06-11 13:45:57 +00:00
|
|
|
|
|
|
|
unrender: function() {
|
|
|
|
for (let animation of this.animations) {
|
|
|
|
animation.off("changed", this.onAnimationStateChanged);
|
|
|
|
}
|
|
|
|
TimeScale.reset();
|
|
|
|
this.destroyTargetNodes();
|
2015-10-16 20:35:28 +00:00
|
|
|
this.destroyTimeBlocks();
|
2015-06-11 13:45:57 +00:00
|
|
|
this.animationsEl.innerHTML = "";
|
|
|
|
},
|
2015-10-19 13:51:24 +00:00
|
|
|
|
2015-10-15 08:14:35 +00:00
|
|
|
onScrubberMouseDown: function(e) {
|
2015-08-21 13:23:17 +00:00
|
|
|
this.moveScrubberTo(e.pageX);
|
2015-10-15 08:14:35 +00:00
|
|
|
this.win.addEventListener("mouseup", this.onScrubberMouseUp);
|
|
|
|
this.win.addEventListener("mouseout", this.onScrubberMouseOut);
|
|
|
|
this.win.addEventListener("mousemove", this.onScrubberMouseMove);
|
|
|
|
|
|
|
|
// Prevent text selection while dragging.
|
|
|
|
e.preventDefault();
|
2015-08-21 13:23:17 +00:00
|
|
|
},
|
|
|
|
|
2015-10-15 08:14:35 +00:00
|
|
|
onScrubberMouseUp: function() {
|
2015-08-21 13:23:17 +00:00
|
|
|
this.cancelTimeHeaderDragging();
|
|
|
|
},
|
|
|
|
|
2015-10-15 08:14:35 +00:00
|
|
|
onScrubberMouseOut: function(e) {
|
2015-08-21 13:23:17 +00:00
|
|
|
// Check that mouseout happened on the window itself, and if yes, cancel
|
|
|
|
// the dragging.
|
|
|
|
if (!this.win.document.contains(e.relatedTarget)) {
|
|
|
|
this.cancelTimeHeaderDragging();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
cancelTimeHeaderDragging: function() {
|
2015-10-15 08:14:35 +00:00
|
|
|
this.win.removeEventListener("mouseup", this.onScrubberMouseUp);
|
|
|
|
this.win.removeEventListener("mouseout", this.onScrubberMouseOut);
|
|
|
|
this.win.removeEventListener("mousemove", this.onScrubberMouseMove);
|
2015-08-21 13:23:17 +00:00
|
|
|
},
|
|
|
|
|
2015-10-15 08:14:35 +00:00
|
|
|
onScrubberMouseMove: function(e) {
|
2015-08-21 13:23:17 +00:00
|
|
|
this.moveScrubberTo(e.pageX);
|
|
|
|
},
|
|
|
|
|
|
|
|
moveScrubberTo: function(pageX) {
|
2015-08-27 14:59:16 +00:00
|
|
|
this.stopAnimatingScrubber();
|
|
|
|
|
2015-08-21 13:23:17 +00:00
|
|
|
let offset = pageX - this.scrubberEl.offsetWidth;
|
|
|
|
if (offset < 0) {
|
|
|
|
offset = 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.scrubberEl.style.left = offset + "px";
|
|
|
|
|
2015-08-24 16:22:15 +00:00
|
|
|
let time = TimeScale.distanceToRelativeTime(offset,
|
|
|
|
this.timeHeaderEl.offsetWidth);
|
2015-09-16 15:00:07 +00:00
|
|
|
|
|
|
|
this.emit("timeline-data-changed", {
|
|
|
|
isPaused: true,
|
|
|
|
isMoving: false,
|
2015-10-19 13:51:24 +00:00
|
|
|
isUserDrag: true,
|
2015-09-16 15:00:07 +00:00
|
|
|
time: time
|
|
|
|
});
|
2015-08-21 13:23:17 +00:00
|
|
|
},
|
|
|
|
|
2015-09-03 07:34:08 +00:00
|
|
|
render: function(animations, documentCurrentTime) {
|
2015-06-11 13:45:57 +00:00
|
|
|
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"
|
|
|
|
}
|
|
|
|
});
|
2015-10-16 20:35:28 +00:00
|
|
|
// Draw the animated node target.
|
|
|
|
let targetNode = new AnimationTargetNode(this.inspector, {compact: true});
|
|
|
|
targetNode.init(animatedNodeEl);
|
|
|
|
targetNode.render(animation);
|
|
|
|
this.targetNodes.push(targetNode);
|
2015-06-11 13:45:57 +00:00
|
|
|
|
2015-10-16 20:35:28 +00:00
|
|
|
// Right-hand part contains the timeline itself (called time-block here).
|
2015-06-11 13:45:57 +00:00
|
|
|
let timeBlockEl = createNode({
|
|
|
|
parent: animationEl,
|
|
|
|
attributes: {
|
|
|
|
"class": "time-block"
|
|
|
|
}
|
|
|
|
});
|
2015-10-16 20:35:28 +00:00
|
|
|
// Draw the animation time block.
|
|
|
|
let timeBlock = new AnimationTimeBlock();
|
|
|
|
timeBlock.init(timeBlockEl);
|
|
|
|
timeBlock.render(animation);
|
|
|
|
this.timeBlocks.push(timeBlock);
|
2015-06-11 13:45:57 +00:00
|
|
|
}
|
2015-08-27 14:59:16 +00:00
|
|
|
// Use the document's current time to position the scrubber (if the server
|
|
|
|
// doesn't provide it, hide the scrubber entirely).
|
|
|
|
// Note that because the currentTime was sent via the protocol, some time
|
|
|
|
// may have gone by since then, and so the scrubber might be a bit late.
|
2015-09-03 07:34:08 +00:00
|
|
|
if (!documentCurrentTime) {
|
2015-08-27 14:59:16 +00:00
|
|
|
this.scrubberEl.style.display = "none";
|
|
|
|
} else {
|
|
|
|
this.scrubberEl.style.display = "block";
|
2015-10-12 09:34:59 +00:00
|
|
|
this.startAnimatingScrubber(this.wasRewound()
|
|
|
|
? TimeScale.minStartTime
|
|
|
|
: documentCurrentTime);
|
2015-08-27 14:59:16 +00:00
|
|
|
}
|
|
|
|
},
|
|
|
|
|
2015-09-18 07:28:14 +00:00
|
|
|
isAtLeastOneAnimationPlaying: function() {
|
|
|
|
return this.animations.some(({state}) => state.playState === "running");
|
|
|
|
},
|
|
|
|
|
2015-10-12 09:34:59 +00:00
|
|
|
wasRewound: function() {
|
|
|
|
return !this.isAtLeastOneAnimationPlaying() &&
|
|
|
|
this.animations.every(({state}) => state.currentTime === 0);
|
|
|
|
},
|
|
|
|
|
2015-10-28 11:58:39 +00:00
|
|
|
hasInfiniteAnimations: function() {
|
|
|
|
return this.animations.some(({state}) => !state.iterationCount);
|
|
|
|
},
|
|
|
|
|
2015-08-27 14:59:16 +00:00
|
|
|
startAnimatingScrubber: function(time) {
|
|
|
|
let x = TimeScale.startTimeToDistance(time, this.timeHeaderEl.offsetWidth);
|
|
|
|
this.scrubberEl.style.left = x + "px";
|
|
|
|
|
2015-10-28 11:58:39 +00:00
|
|
|
// Only stop the scrubber if it's out of bounds or all animations have been
|
|
|
|
// paused, but not if at least an animation is infinite.
|
|
|
|
let isOutOfBounds = time < TimeScale.minStartTime ||
|
|
|
|
time > TimeScale.maxEndTime;
|
|
|
|
let isAllPaused = !this.isAtLeastOneAnimationPlaying();
|
|
|
|
let hasInfinite = this.hasInfiniteAnimations();
|
|
|
|
|
|
|
|
if (isAllPaused || (isOutOfBounds && !hasInfinite)) {
|
2015-09-16 15:00:07 +00:00
|
|
|
this.stopAnimatingScrubber();
|
|
|
|
this.emit("timeline-data-changed", {
|
2015-10-14 08:03:29 +00:00
|
|
|
isPaused: !this.isAtLeastOneAnimationPlaying(),
|
2015-09-16 15:00:07 +00:00
|
|
|
isMoving: false,
|
2015-10-19 13:51:24 +00:00
|
|
|
isUserDrag: false,
|
2015-09-16 15:00:07 +00:00
|
|
|
time: TimeScale.distanceToRelativeTime(x, this.timeHeaderEl.offsetWidth)
|
|
|
|
});
|
2015-08-27 14:59:16 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2015-09-16 15:00:07 +00:00
|
|
|
this.emit("timeline-data-changed", {
|
|
|
|
isPaused: false,
|
|
|
|
isMoving: true,
|
2015-10-19 13:51:24 +00:00
|
|
|
isUserDrag: false,
|
2015-09-16 15:00:07 +00:00
|
|
|
time: TimeScale.distanceToRelativeTime(x, this.timeHeaderEl.offsetWidth)
|
|
|
|
});
|
|
|
|
|
2015-08-27 14:59:16 +00:00
|
|
|
let now = this.win.performance.now();
|
|
|
|
this.rafID = this.win.requestAnimationFrame(() => {
|
2015-09-16 15:00:07 +00:00
|
|
|
if (!this.rafID) {
|
|
|
|
// In case the scrubber was stopped in the meantime.
|
|
|
|
return;
|
|
|
|
}
|
2015-08-27 14:59:16 +00:00
|
|
|
this.startAnimatingScrubber(time + this.win.performance.now() - now);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
stopAnimatingScrubber: function() {
|
|
|
|
if (this.rafID) {
|
|
|
|
this.win.cancelAnimationFrame(this.rafID);
|
2015-09-16 15:00:07 +00:00
|
|
|
this.rafID = null;
|
2015-08-27 14:59:16 +00:00
|
|
|
}
|
2015-06-11 13:45:57 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
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);
|
2015-08-13 12:07:51 +00:00
|
|
|
drawGraphElementBackground(this.win.document, "time-graduations",
|
|
|
|
width, scale);
|
2015-06-11 13:45:57 +00:00
|
|
|
|
|
|
|
// 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))
|
|
|
|
});
|
|
|
|
}
|
2015-10-16 20:35:28 +00:00
|
|
|
}
|
|
|
|
};
|
2015-09-01 15:01:48 +00:00
|
|
|
|
2015-10-16 20:35:28 +00:00
|
|
|
/**
|
|
|
|
* UI component responsible for displaying a single animation timeline, which
|
|
|
|
* basically looks like a rectangle that shows the delay and iterations.
|
|
|
|
*/
|
|
|
|
function AnimationTimeBlock() {}
|
2015-09-01 15:01:48 +00:00
|
|
|
|
2015-10-16 20:35:28 +00:00
|
|
|
exports.AnimationTimeBlock = AnimationTimeBlock;
|
2015-10-16 15:10:21 +00:00
|
|
|
|
2015-10-16 20:35:28 +00:00
|
|
|
AnimationTimeBlock.prototype = {
|
|
|
|
init: function(containerEl) {
|
|
|
|
this.containerEl = containerEl;
|
|
|
|
},
|
|
|
|
destroy: function() {
|
|
|
|
while (this.containerEl.firstChild) {
|
|
|
|
this.containerEl.firstChild.remove();
|
|
|
|
}
|
|
|
|
this.containerEl = null;
|
|
|
|
this.animation = null;
|
2015-09-01 15:01:48 +00:00
|
|
|
},
|
|
|
|
|
2015-10-16 20:35:28 +00:00
|
|
|
render: function(animation) {
|
|
|
|
this.animation = animation;
|
|
|
|
let {state} = this.animation;
|
|
|
|
|
|
|
|
let width = this.containerEl.offsetWidth;
|
2015-06-11 13:45:57 +00:00
|
|
|
|
2015-08-26 08:39:16 +00:00
|
|
|
// Create a container element to hold the delay and iterations.
|
|
|
|
// It is positioned according to its delay (divided by the playbackrate),
|
|
|
|
// and its width is according to its duration (divided by the playbackrate).
|
2015-09-18 07:28:14 +00:00
|
|
|
let start = state.previousStartTime || 0;
|
2015-08-26 08:39:16 +00:00
|
|
|
let duration = state.duration;
|
|
|
|
let rate = state.playbackRate;
|
|
|
|
let count = state.iterationCount;
|
|
|
|
let delay = state.delay || 0;
|
|
|
|
|
|
|
|
let x = TimeScale.startTimeToDistance(start + (delay / rate), width);
|
|
|
|
let w = TimeScale.durationToDistance(duration / rate, width);
|
2015-06-11 13:45:57 +00:00
|
|
|
|
|
|
|
let iterations = createNode({
|
2015-10-16 20:35:28 +00:00
|
|
|
parent: this.containerEl,
|
2015-06-11 13:45:57 +00:00
|
|
|
attributes: {
|
2015-08-31 13:28:32 +00:00
|
|
|
"class": state.type + " iterations" + (count ? "" : " infinite"),
|
2015-06-11 13:45:57 +00:00
|
|
|
// Individual iterations are represented by setting the size of the
|
|
|
|
// repeating linear-gradient.
|
|
|
|
"style": `left:${x}px;
|
2015-08-26 08:39:16 +00:00
|
|
|
width:${w * (count || 1)}px;
|
2015-06-11 13:45:57 +00:00
|
|
|
background-size:${Math.max(w, 2)}px 100%;`
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// The animation name is displayed over the iterations.
|
2015-09-04 15:43:41 +00:00
|
|
|
// Note that in case of negative delay, we push the name towards the right
|
|
|
|
// so the delay can be shown.
|
2015-06-11 13:45:57 +00:00
|
|
|
createNode({
|
|
|
|
parent: iterations,
|
|
|
|
attributes: {
|
2015-08-27 14:48:37 +00:00
|
|
|
"class": "name",
|
2015-10-16 20:35:28 +00:00
|
|
|
"title": this.getTooltipText(state),
|
2015-09-04 15:43:41 +00:00
|
|
|
"style": delay < 0
|
|
|
|
? "margin-left:" +
|
|
|
|
TimeScale.durationToDistance(Math.abs(delay), width) + "px"
|
|
|
|
: ""
|
2015-06-11 13:45:57 +00:00
|
|
|
},
|
|
|
|
textContent: state.name
|
|
|
|
});
|
|
|
|
|
|
|
|
// Delay.
|
2015-08-26 08:39:16 +00:00
|
|
|
if (delay) {
|
2015-09-04 15:43:41 +00:00
|
|
|
// Negative delays need to start at 0.
|
2015-10-16 20:35:28 +00:00
|
|
|
let delayX = TimeScale.durationToDistance(
|
|
|
|
(delay < 0 ? 0 : delay) / rate, width);
|
|
|
|
let delayW = TimeScale.durationToDistance(
|
|
|
|
Math.abs(delay) / rate, width);
|
|
|
|
|
2015-06-11 13:45:57 +00:00
|
|
|
createNode({
|
|
|
|
parent: iterations,
|
|
|
|
attributes: {
|
2015-09-09 09:07:29 +00:00
|
|
|
"class": "delay" + (delay < 0 ? " negative" : ""),
|
2015-10-16 20:35:28 +00:00
|
|
|
"style": `left:-${delayX}px;
|
|
|
|
width:${delayW}px;`
|
2015-06-11 13:45:57 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2015-10-16 20:35:28 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
getTooltipText: function(state) {
|
|
|
|
let getTime = time => L10N.getFormatStr("player.timeLabel",
|
|
|
|
L10N.numberWithDecimals(time / 1000, 2));
|
|
|
|
// The type isn't always available, older servers don't send it.
|
|
|
|
let title =
|
|
|
|
state.type
|
|
|
|
? L10N.getFormatStr("timeline." + state.type + ".nameLabel", state.name)
|
|
|
|
: state.name;
|
|
|
|
let delay = L10N.getStr("player.animationDelayLabel") + " " +
|
|
|
|
getTime(state.delay);
|
|
|
|
let duration = L10N.getStr("player.animationDurationLabel") + " " +
|
|
|
|
getTime(state.duration);
|
|
|
|
let iterations = L10N.getStr("player.animationIterationCountLabel") + " " +
|
|
|
|
(state.iterationCount ||
|
|
|
|
L10N.getStr("player.infiniteIterationCountText"));
|
|
|
|
return [title, duration, iterations, delay].join("\n");
|
2015-06-11 13:45:57 +00:00
|
|
|
}
|
|
|
|
};
|