summaryrefslogtreecommitdiffstats
path: root/devtools/client/animationinspector/components
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/animationinspector/components')
-rw-r--r--devtools/client/animationinspector/components/animation-details.js222
-rw-r--r--devtools/client/animationinspector/components/animation-target-node.js80
-rw-r--r--devtools/client/animationinspector/components/animation-time-block.js719
-rw-r--r--devtools/client/animationinspector/components/animation-timeline.js502
-rw-r--r--devtools/client/animationinspector/components/keyframes.js81
-rw-r--r--devtools/client/animationinspector/components/moz.build12
-rw-r--r--devtools/client/animationinspector/components/rate-selector.js105
7 files changed, 1721 insertions, 0 deletions
diff --git a/devtools/client/animationinspector/components/animation-details.js b/devtools/client/animationinspector/components/animation-details.js
new file mode 100644
index 000000000..c042ccac0
--- /dev/null
+++ b/devtools/client/animationinspector/components/animation-details.js
@@ -0,0 +1,222 @@
+/* -*- 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";
+
+const {Task} = require("devtools/shared/task");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {createNode, TimeScale} = require("devtools/client/animationinspector/utils");
+const {Keyframes} = require("devtools/client/animationinspector/components/keyframes");
+
+/**
+ * UI component responsible for displaying detailed information for a given
+ * animation.
+ * This includes information about timing, easing, keyframes, animated
+ * properties.
+ *
+ * @param {Object} serverTraits The list of server-side capabilities.
+ */
+function AnimationDetails(serverTraits) {
+ EventEmitter.decorate(this);
+
+ this.onFrameSelected = this.onFrameSelected.bind(this);
+
+ this.keyframeComponents = [];
+ this.serverTraits = serverTraits;
+}
+
+exports.AnimationDetails = AnimationDetails;
+
+AnimationDetails.prototype = {
+ // These are part of frame objects but are not animated properties. This
+ // array is used to skip them.
+ NON_PROPERTIES: ["easing", "composite", "computedOffset", "offset"],
+
+ init: function (containerEl) {
+ this.containerEl = containerEl;
+ },
+
+ destroy: function () {
+ this.unrender();
+ this.containerEl = null;
+ this.serverTraits = null;
+ },
+
+ unrender: function () {
+ for (let component of this.keyframeComponents) {
+ component.off("frame-selected", this.onFrameSelected);
+ component.destroy();
+ }
+ this.keyframeComponents = [];
+
+ while (this.containerEl.firstChild) {
+ this.containerEl.firstChild.remove();
+ }
+ },
+
+ getPerfDataForProperty: function (animation, propertyName) {
+ let warning = "";
+ let className = "";
+ if (animation.state.propertyState) {
+ let isRunningOnCompositor;
+ for (let propState of animation.state.propertyState) {
+ if (propState.property == propertyName) {
+ isRunningOnCompositor = propState.runningOnCompositor;
+ if (typeof propState.warning != "undefined") {
+ warning = propState.warning;
+ }
+ break;
+ }
+ }
+ if (isRunningOnCompositor && warning == "") {
+ className = "oncompositor";
+ } else if (!isRunningOnCompositor && warning != "") {
+ className = "warning";
+ }
+ }
+ return {className, warning};
+ },
+
+ /**
+ * Get a list of the tracks of the animation actor
+ * @return {Object} A list of tracks, one per animated property, each
+ * with a list of keyframes
+ */
+ getTracks: Task.async(function* () {
+ let tracks = {};
+
+ /*
+ * getFrames is a AnimationPlayorActor method that returns data about the
+ * keyframes of the animation.
+ * In FF48, the data it returns change, and will hold only longhand
+ * properties ( e.g. borderLeftWidth ), which does not match what we
+ * want to display in the animation detail.
+ * A new AnimationPlayerActor function, getProperties, is introduced,
+ * that returns the animated css properties of the animation and their
+ * keyframes values.
+ * If the animation actor has the getProperties function, we use it, and if
+ * not, we fall back to getFrames, which then returns values we used to
+ * handle.
+ */
+ if (this.serverTraits.hasGetProperties) {
+ let properties = yield this.animation.getProperties();
+ for (let {name, values} of properties) {
+ if (!tracks[name]) {
+ tracks[name] = [];
+ }
+
+ for (let {value, offset} of values) {
+ tracks[name].push({value, offset});
+ }
+ }
+ } else {
+ let frames = yield this.animation.getFrames();
+ for (let frame of frames) {
+ for (let name in frame) {
+ if (this.NON_PROPERTIES.indexOf(name) != -1) {
+ continue;
+ }
+
+ if (!tracks[name]) {
+ tracks[name] = [];
+ }
+
+ tracks[name].push({
+ value: frame[name],
+ offset: frame.computedOffset
+ });
+ }
+ }
+ }
+
+ return tracks;
+ }),
+
+ render: Task.async(function* (animation) {
+ this.unrender();
+
+ if (!animation) {
+ return;
+ }
+ this.animation = animation;
+
+ // We might have been destroyed in the meantime, or the component might
+ // have been re-rendered.
+ if (!this.containerEl || this.animation !== animation) {
+ return;
+ }
+
+ // Build an element for each animated property track.
+ this.tracks = yield this.getTracks(animation, this.serverTraits);
+
+ // Useful for tests to know when the keyframes have been retrieved.
+ this.emit("keyframes-retrieved");
+
+ for (let propertyName in this.tracks) {
+ let line = createNode({
+ parent: this.containerEl,
+ attributes: {"class": "property"}
+ });
+ let {warning, className} =
+ this.getPerfDataForProperty(animation, propertyName);
+ createNode({
+ // text-overflow doesn't work in flex items, so we need a second level
+ // of container to actually have an ellipsis on the name.
+ // See bug 972664.
+ parent: createNode({
+ parent: line,
+ attributes: {"class": "name"}
+ }),
+ textContent: getCssPropertyName(propertyName),
+ attributes: {"title": warning,
+ "class": className}
+ });
+
+ // Add the keyframes diagram for this property.
+ let framesWrapperEl = createNode({
+ parent: line,
+ attributes: {"class": "track-container"}
+ });
+
+ let framesEl = createNode({
+ parent: framesWrapperEl,
+ attributes: {"class": "frames"}
+ });
+
+ // Scale the list of keyframes according to the current time scale.
+ let {x, w} = TimeScale.getAnimationDimensions(animation);
+ framesEl.style.left = `${x}%`;
+ framesEl.style.width = `${w}%`;
+
+ let keyframesComponent = new Keyframes();
+ keyframesComponent.init(framesEl);
+ keyframesComponent.render({
+ keyframes: this.tracks[propertyName],
+ propertyName: propertyName,
+ animation: animation
+ });
+ keyframesComponent.on("frame-selected", this.onFrameSelected);
+
+ this.keyframeComponents.push(keyframesComponent);
+ }
+ }),
+
+ onFrameSelected: function (e, args) {
+ // Relay the event up, it's needed in parents too.
+ this.emit(e, args);
+ }
+};
+
+/**
+ * Turn propertyName into property-name.
+ * @param {String} jsPropertyName A camelcased CSS property name. Typically
+ * something that comes out of computed styles. E.g. borderBottomColor
+ * @return {String} The corresponding CSS property name: border-bottom-color
+ */
+function getCssPropertyName(jsPropertyName) {
+ return jsPropertyName.replace(/[A-Z]/g, "-$&").toLowerCase();
+}
+exports.getCssPropertyName = getCssPropertyName;
diff --git a/devtools/client/animationinspector/components/animation-target-node.js b/devtools/client/animationinspector/components/animation-target-node.js
new file mode 100644
index 000000000..c300e9ce7
--- /dev/null
+++ b/devtools/client/animationinspector/components/animation-target-node.js
@@ -0,0 +1,80 @@
+/* -*- 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";
+
+const {Task} = require("devtools/shared/task");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {DomNodePreview} = require("devtools/client/inspector/shared/dom-node-preview");
+
+// Map dom node fronts by animation fronts so we don't have to get them from the
+// walker every time the timeline is refreshed.
+var nodeFronts = new WeakMap();
+
+/**
+ * UI component responsible for displaying a preview of the target dom node of
+ * a given animation.
+ * Accepts the same parameters as the DomNodePreview component. See
+ * devtools/client/inspector/shared/dom-node-preview.js for documentation.
+ */
+function AnimationTargetNode(inspector, options) {
+ this.inspector = inspector;
+ this.previewer = new DomNodePreview(inspector, options);
+ EventEmitter.decorate(this);
+}
+
+exports.AnimationTargetNode = AnimationTargetNode;
+
+AnimationTargetNode.prototype = {
+ init: function (containerEl) {
+ this.previewer.init(containerEl);
+ this.isDestroyed = false;
+ },
+
+ destroy: function () {
+ this.previewer.destroy();
+ this.inspector = null;
+ this.isDestroyed = true;
+ },
+
+ render: Task.async(function* (playerFront) {
+ // Get the nodeFront from the cache if it was stored previously.
+ let nodeFront = nodeFronts.get(playerFront);
+
+ // Try and get it from the playerFront directly next.
+ if (!nodeFront) {
+ nodeFront = playerFront.animationTargetNodeFront;
+ }
+
+ // Finally, get it from the walkerActor if it wasn't found.
+ if (!nodeFront) {
+ try {
+ nodeFront = yield this.inspector.walker.getNodeFromActor(
+ playerFront.actorID, ["node"]);
+ } catch (e) {
+ // If an error occured while getting the nodeFront and if it can't be
+ // attributed to the panel having been destroyed in the meantime, this
+ // error needs to be logged and render needs to stop.
+ if (!this.isDestroyed) {
+ console.error(e);
+ }
+ return;
+ }
+
+ // In all cases, if by now the panel doesn't exist anymore, we need to
+ // stop rendering too.
+ if (this.isDestroyed) {
+ return;
+ }
+ }
+
+ // Add the nodeFront to the cache.
+ nodeFronts.set(playerFront, nodeFront);
+
+ this.previewer.render(nodeFront);
+ this.emit("target-retrieved");
+ })
+};
diff --git a/devtools/client/animationinspector/components/animation-time-block.js b/devtools/client/animationinspector/components/animation-time-block.js
new file mode 100644
index 000000000..51392da8f
--- /dev/null
+++ b/devtools/client/animationinspector/components/animation-time-block.js
@@ -0,0 +1,719 @@
+/* -*- 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";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const {createNode, TimeScale} = require("devtools/client/animationinspector/utils");
+
+const { LocalizationHelper } = require("devtools/shared/l10n");
+const L10N =
+ new LocalizationHelper("devtools/client/locales/animationinspector.properties");
+
+// In the createPathSegments function, an animation duration is divided by
+// DURATION_RESOLUTION in order to draw the way the animation progresses.
+// But depending on the timing-function, we may be not able to make the graph
+// smoothly progress if this resolution is not high enough.
+// So, if the difference of animation progress between 2 divisions is more than
+// MIN_PROGRESS_THRESHOLD, then createPathSegments re-divides
+// by DURATION_RESOLUTION.
+// DURATION_RESOLUTION shoud be integer and more than 2.
+const DURATION_RESOLUTION = 4;
+// MIN_PROGRESS_THRESHOLD shoud be between more than 0 to 1.
+const MIN_PROGRESS_THRESHOLD = 0.1;
+// Show max 10 iterations for infinite animations
+// to give users a clue that the animation does repeat.
+const MAX_INFINITE_ANIMATIONS_ITERATIONS = 10;
+// SVG namespace
+const SVG_NS = "http://www.w3.org/2000/svg";
+
+/**
+ * UI component responsible for displaying a single animation timeline, which
+ * basically looks like a rectangle that shows the delay and iterations.
+ */
+function AnimationTimeBlock() {
+ EventEmitter.decorate(this);
+ this.onClick = this.onClick.bind(this);
+}
+
+exports.AnimationTimeBlock = AnimationTimeBlock;
+
+AnimationTimeBlock.prototype = {
+ init: function (containerEl) {
+ this.containerEl = containerEl;
+ this.containerEl.addEventListener("click", this.onClick);
+ },
+
+ destroy: function () {
+ this.containerEl.removeEventListener("click", this.onClick);
+ this.unrender();
+ this.containerEl = null;
+ this.animation = null;
+ },
+
+ unrender: function () {
+ while (this.containerEl.firstChild) {
+ this.containerEl.firstChild.remove();
+ }
+ },
+
+ render: function (animation) {
+ this.unrender();
+
+ this.animation = animation;
+ let {state} = this.animation;
+
+ // 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).
+ const {x, delayX, delayW, endDelayX, endDelayW} =
+ TimeScale.getAnimationDimensions(animation);
+
+ // Animation summary graph element.
+ const summaryEl = createNode({
+ parent: this.containerEl,
+ namespace: "http://www.w3.org/2000/svg",
+ nodeType: "svg",
+ attributes: {
+ "class": "summary",
+ "preserveAspectRatio": "none",
+ "style": `left: ${ x - (state.delay > 0 ? delayW : 0) }%`
+ }
+ });
+
+ // Total displayed duration
+ const totalDisplayedDuration = state.playbackRate * TimeScale.getDuration();
+
+ // Calculate stroke height in viewBox to display stroke of path.
+ const strokeHeightForViewBox = 0.5 / this.containerEl.clientHeight;
+
+ // Set viewBox
+ summaryEl.setAttribute("viewBox",
+ `${ state.delay < 0 ? state.delay : 0 }
+ -${ 1 + strokeHeightForViewBox }
+ ${ totalDisplayedDuration }
+ ${ 1 + strokeHeightForViewBox * 2 }`);
+
+ // Get a helper function that returns the path segment of timing-function.
+ const segmentHelper = getSegmentHelper(state, this.win);
+
+ // Minimum segment duration is the duration of one pixel.
+ const minSegmentDuration =
+ totalDisplayedDuration / this.containerEl.clientWidth;
+ // Minimum progress threshold.
+ let minProgressThreshold = MIN_PROGRESS_THRESHOLD;
+ // If the easing is step function,
+ // minProgressThreshold should be changed by the steps.
+ const stepFunction = state.easing.match(/steps\((\d+)/);
+ if (stepFunction) {
+ minProgressThreshold = 1 / (parseInt(stepFunction[1], 10) + 1);
+ }
+
+ // Starting time of main iteration.
+ let mainIterationStartTime = 0;
+ let iterationStart = state.iterationStart;
+ let iterationCount = state.iterationCount ? state.iterationCount : Infinity;
+
+ // Append delay.
+ if (state.delay > 0) {
+ renderDelay(summaryEl, state, segmentHelper);
+ mainIterationStartTime = state.delay;
+ } else {
+ const negativeDelayCount = -state.delay / state.duration;
+ // Move to forward the starting point for negative delay.
+ iterationStart += negativeDelayCount;
+ // Consume iteration count by negative delay.
+ if (iterationCount !== Infinity) {
+ iterationCount -= negativeDelayCount;
+ }
+ }
+
+ // Append 1st section of iterations,
+ // This section is only useful in cases where iterationStart has decimals.
+ // e.g.
+ // if { iterationStart: 0.25, iterations: 3 }, firstSectionCount is 0.75.
+ const firstSectionCount =
+ iterationStart % 1 === 0
+ ? 0 : Math.min(iterationCount, 1) - iterationStart % 1;
+ if (firstSectionCount) {
+ renderFirstIteration(summaryEl, state, mainIterationStartTime,
+ firstSectionCount, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ }
+
+ if (iterationCount === Infinity) {
+ // If the animation repeats infinitely,
+ // we fill the remaining area with iteration paths.
+ renderInfinity(summaryEl, state, mainIterationStartTime,
+ firstSectionCount, totalDisplayedDuration,
+ minSegmentDuration, minProgressThreshold, segmentHelper);
+ } else {
+ // Otherwise, we show remaining iterations, endDelay and fill.
+
+ // Append forwards fill-mode.
+ if (state.fill === "both" || state.fill === "forwards") {
+ renderForwardsFill(summaryEl, state, mainIterationStartTime,
+ iterationCount, totalDisplayedDuration,
+ segmentHelper);
+ }
+
+ // Append middle section of iterations.
+ // e.g.
+ // if { iterationStart: 0.25, iterations: 3 }, middleSectionCount is 2.
+ const middleSectionCount =
+ Math.floor(iterationCount - firstSectionCount);
+ renderMiddleIterations(summaryEl, state, mainIterationStartTime,
+ firstSectionCount, middleSectionCount,
+ minSegmentDuration, minProgressThreshold,
+ segmentHelper);
+
+ // Append last section of iterations, if there is remaining iteration.
+ // e.g.
+ // if { iterationStart: 0.25, iterations: 3 }, lastSectionCount is 0.25.
+ const lastSectionCount =
+ iterationCount - middleSectionCount - firstSectionCount;
+ if (lastSectionCount) {
+ renderLastIteration(summaryEl, state, mainIterationStartTime,
+ firstSectionCount, middleSectionCount,
+ lastSectionCount, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ }
+
+ // Append endDelay.
+ if (state.endDelay > 0) {
+ renderEndDelay(summaryEl, state,
+ mainIterationStartTime, iterationCount, segmentHelper);
+ }
+ }
+
+ // Append negative delay (which overlap the animation).
+ if (state.delay < 0) {
+ segmentHelper.animation.effect.timing.fill = "both";
+ segmentHelper.asOriginalBehavior = false;
+ renderNegativeDelayHiddenProgress(summaryEl, state, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ }
+ // Append negative endDelay (which overlap the animation).
+ if (state.iterationCount && state.endDelay < 0) {
+ if (segmentHelper.asOriginalBehavior) {
+ segmentHelper.animation.effect.timing.fill = "both";
+ segmentHelper.asOriginalBehavior = false;
+ }
+ renderNegativeEndDelayHiddenProgress(summaryEl, state,
+ minSegmentDuration,
+ minProgressThreshold,
+ segmentHelper);
+ }
+
+ // The animation name is displayed over the animation.
+ createNode({
+ parent: createNode({
+ parent: this.containerEl,
+ attributes: {
+ "class": "name",
+ "title": this.getTooltipText(state)
+ },
+ }),
+ textContent: state.name
+ });
+
+ // Delay.
+ if (state.delay) {
+ // Negative delays need to start at 0.
+ createNode({
+ parent: this.containerEl,
+ attributes: {
+ "class": "delay"
+ + (state.delay < 0 ? " negative" : " positive")
+ + (state.fill === "both" ||
+ state.fill === "backwards" ? " fill" : ""),
+ "style": `left:${ delayX }%; width:${ delayW }%;`
+ }
+ });
+ }
+
+ // endDelay
+ if (state.iterationCount && state.endDelay) {
+ createNode({
+ parent: this.containerEl,
+ attributes: {
+ "class": "end-delay"
+ + (state.endDelay < 0 ? " negative" : " positive")
+ + (state.fill === "both" ||
+ state.fill === "forwards" ? " fill" : ""),
+ "style": `left:${ endDelayX }%; width:${ endDelayW }%;`
+ }
+ });
+ }
+ },
+
+ getTooltipText: function (state) {
+ let getTime = time => L10N.getFormatStr("player.timeLabel",
+ L10N.numberWithDecimals(time / 1000, 2));
+
+ let text = "";
+
+ // Adding the name.
+ text += getFormattedAnimationTitle({state});
+ text += "\n";
+
+ // Adding the delay.
+ if (state.delay) {
+ text += L10N.getStr("player.animationDelayLabel") + " ";
+ text += getTime(state.delay);
+ text += "\n";
+ }
+
+ // Adding the duration.
+ text += L10N.getStr("player.animationDurationLabel") + " ";
+ text += getTime(state.duration);
+ text += "\n";
+
+ // Adding the endDelay.
+ if (state.endDelay) {
+ text += L10N.getStr("player.animationEndDelayLabel") + " ";
+ text += getTime(state.endDelay);
+ text += "\n";
+ }
+
+ // Adding the iteration count (the infinite symbol, or an integer).
+ if (state.iterationCount !== 1) {
+ text += L10N.getStr("player.animationIterationCountLabel") + " ";
+ text += state.iterationCount ||
+ L10N.getStr("player.infiniteIterationCountText");
+ text += "\n";
+ }
+
+ // Adding the iteration start.
+ if (state.iterationStart !== 0) {
+ let iterationStartTime = state.iterationStart * state.duration / 1000;
+ text += L10N.getFormatStr("player.animationIterationStartLabel",
+ state.iterationStart,
+ L10N.numberWithDecimals(iterationStartTime, 2));
+ text += "\n";
+ }
+
+ // Adding the easing.
+ if (state.easing) {
+ text += L10N.getStr("player.animationEasingLabel") + " ";
+ text += state.easing;
+ text += "\n";
+ }
+
+ // Adding the fill mode.
+ if (state.fill) {
+ text += L10N.getStr("player.animationFillLabel") + " ";
+ text += state.fill;
+ text += "\n";
+ }
+
+ // Adding the direction mode.
+ if (state.direction) {
+ text += L10N.getStr("player.animationDirectionLabel") + " ";
+ text += state.direction;
+ text += "\n";
+ }
+
+ // Adding the playback rate if it's different than 1.
+ if (state.playbackRate !== 1) {
+ text += L10N.getStr("player.animationRateLabel") + " ";
+ text += state.playbackRate;
+ text += "\n";
+ }
+
+ // Adding a note that the animation is running on the compositor thread if
+ // needed.
+ if (state.propertyState) {
+ if (state.propertyState
+ .every(propState => propState.runningOnCompositor)) {
+ text += L10N.getStr("player.allPropertiesOnCompositorTooltip");
+ } else if (state.propertyState
+ .some(propState => propState.runningOnCompositor)) {
+ text += L10N.getStr("player.somePropertiesOnCompositorTooltip");
+ }
+ } else if (state.isRunningOnCompositor) {
+ text += L10N.getStr("player.runningOnCompositorTooltip");
+ }
+
+ return text;
+ },
+
+ onClick: function (e) {
+ e.stopPropagation();
+ this.emit("selected", this.animation);
+ },
+
+ get win() {
+ return this.containerEl.ownerDocument.defaultView;
+ }
+};
+
+/**
+ * Get a formatted title for this animation. This will be either:
+ * "some-name", "some-name : CSS Transition", "some-name : CSS Animation",
+ * "some-name : Script Animation", or "Script Animation", depending
+ * if the server provides the type, what type it is and if the animation
+ * has a name
+ * @param {AnimationPlayerFront} animation
+ */
+function getFormattedAnimationTitle({state}) {
+ // Older servers don't send a type, and only know about
+ // CSSAnimations and CSSTransitions, so it's safe to use
+ // just the name.
+ if (!state.type) {
+ return state.name;
+ }
+
+ // Script-generated animations may not have a name.
+ if (state.type === "scriptanimation" && !state.name) {
+ return L10N.getStr("timeline.scriptanimation.unnamedLabel");
+ }
+
+ return L10N.getFormatStr(`timeline.${state.type}.nameLabel`, state.name);
+}
+
+/**
+ * Render delay section.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderDelay(parentEl, state, segmentHelper) {
+ const startSegment = segmentHelper.getSegment(0);
+ const endSegment = { x: state.delay, y: startSegment.y };
+ appendPathElement(parentEl, [startSegment, endSegment], "delay-path");
+}
+
+/**
+ * Render first iteration section.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Number} mainIterationStartTime - Starting time of main iteration.
+ * @param {Number} firstSectionCount - Iteration count of first section.
+ * @param {Number} minSegmentDuration - Minimum segment duration.
+ * @param {Number} minProgressThreshold - Minimum progress threshold.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderFirstIteration(parentEl, state, mainIterationStartTime,
+ firstSectionCount, minSegmentDuration,
+ minProgressThreshold, segmentHelper) {
+ const startTime = mainIterationStartTime;
+ const endTime = startTime + firstSectionCount * state.duration;
+ const segments =
+ createPathSegments(startTime, endTime, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ appendPathElement(parentEl, segments, "iteration-path");
+}
+
+/**
+ * Render middle iterations section.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Number} mainIterationStartTime - Starting time of main iteration.
+ * @param {Number} firstSectionCount - Iteration count of first section.
+ * @param {Number} middleSectionCount - Iteration count of middle section.
+ * @param {Number} minSegmentDuration - Minimum segment duration.
+ * @param {Number} minProgressThreshold - Minimum progress threshold.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderMiddleIterations(parentEl, state, mainIterationStartTime,
+ firstSectionCount, middleSectionCount,
+ minSegmentDuration, minProgressThreshold,
+ segmentHelper) {
+ const offset = mainIterationStartTime + firstSectionCount * state.duration;
+ for (let i = 0; i < middleSectionCount; i++) {
+ // Get the path segments of each iteration.
+ const startTime = offset + i * state.duration;
+ const endTime = startTime + state.duration;
+ const segments =
+ createPathSegments(startTime, endTime, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ appendPathElement(parentEl, segments, "iteration-path");
+ }
+}
+
+/**
+ * Render last iteration section.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Number} mainIterationStartTime - Starting time of main iteration.
+ * @param {Number} firstSectionCount - Iteration count of first section.
+ * @param {Number} middleSectionCount - Iteration count of middle section.
+ * @param {Number} lastSectionCount - Iteration count of last section.
+ * @param {Number} minSegmentDuration - Minimum segment duration.
+ * @param {Number} minProgressThreshold - Minimum progress threshold.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderLastIteration(parentEl, state, mainIterationStartTime,
+ firstSectionCount, middleSectionCount,
+ lastSectionCount, minSegmentDuration,
+ minProgressThreshold, segmentHelper) {
+ const startTime = mainIterationStartTime +
+ (firstSectionCount + middleSectionCount) * state.duration;
+ const endTime = startTime + lastSectionCount * state.duration;
+ const segments =
+ createPathSegments(startTime, endTime, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ appendPathElement(parentEl, segments, "iteration-path");
+}
+
+/**
+ * Render Infinity iterations.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Number} mainIterationStartTime - Starting time of main iteration.
+ * @param {Number} firstSectionCount - Iteration count of first section.
+ * @param {Number} totalDuration - Displayed max duration.
+ * @param {Number} minSegmentDuration - Minimum segment duration.
+ * @param {Number} minProgressThreshold - Minimum progress threshold.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderInfinity(parentEl, state, mainIterationStartTime,
+ firstSectionCount, totalDuration, minSegmentDuration,
+ minProgressThreshold, segmentHelper) {
+ // Calculate the number of iterations to display,
+ // with a maximum of MAX_INFINITE_ANIMATIONS_ITERATIONS
+ let uncappedInfinityIterationCount =
+ (totalDuration - firstSectionCount * state.duration) / state.duration;
+ // If there is a small floating point error resulting in, e.g. 1.0000001
+ // ceil will give us 2 so round first.
+ uncappedInfinityIterationCount =
+ parseFloat(uncappedInfinityIterationCount.toPrecision(6));
+ const infinityIterationCount =
+ Math.min(MAX_INFINITE_ANIMATIONS_ITERATIONS,
+ Math.ceil(uncappedInfinityIterationCount));
+
+ // Append first full iteration path.
+ const firstStartTime =
+ mainIterationStartTime + firstSectionCount * state.duration;
+ const firstEndTime = firstStartTime + state.duration;
+ const firstSegments =
+ createPathSegments(firstStartTime, firstEndTime, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ appendPathElement(parentEl, firstSegments, "iteration-path infinity");
+
+ // Append other iterations. We can copy first segments.
+ const isAlternate = state.direction.match(/alternate/);
+ for (let i = 1; i < infinityIterationCount; i++) {
+ const startTime = firstStartTime + i * state.duration;
+ let segments;
+ if (isAlternate && i % 2) {
+ // Copy as reverse.
+ segments = firstSegments.map(segment => {
+ return { x: firstEndTime - segment.x + startTime, y: segment.y };
+ });
+ } else {
+ // Copy as is.
+ segments = firstSegments.map(segment => {
+ return { x: segment.x - firstStartTime + startTime, y: segment.y };
+ });
+ }
+ appendPathElement(parentEl, segments, "iteration-path infinity copied");
+ }
+}
+
+/**
+ * Render endDelay section.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Number} mainIterationStartTime - Starting time of main iteration.
+ * @param {Number} iterationCount - Whole iteration count.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderEndDelay(parentEl, state,
+ mainIterationStartTime, iterationCount, segmentHelper) {
+ const startTime = mainIterationStartTime + iterationCount * state.duration;
+ const startSegment = segmentHelper.getSegment(startTime);
+ const endSegment = { x: startTime + state.endDelay, y: startSegment.y };
+ appendPathElement(parentEl, [startSegment, endSegment], "enddelay-path");
+}
+
+/**
+ * Render forwards fill section.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Number} mainIterationStartTime - Starting time of main iteration.
+ * @param {Number} iterationCount - Whole iteration count.
+ * @param {Number} totalDuration - Displayed max duration.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderForwardsFill(parentEl, state, mainIterationStartTime,
+ iterationCount, totalDuration, segmentHelper) {
+ const startTime = mainIterationStartTime + iterationCount * state.duration +
+ (state.endDelay > 0 ? state.endDelay : 0);
+ const startSegment = segmentHelper.getSegment(startTime);
+ const endSegment = { x: totalDuration, y: startSegment.y };
+ appendPathElement(parentEl, [startSegment, endSegment], "fill-forwards-path");
+}
+
+/**
+ * Render hidden progress of negative delay.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Number} minSegmentDuration - Minimum segment duration.
+ * @param {Number} minProgressThreshold - Minimum progress threshold.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderNegativeDelayHiddenProgress(parentEl, state, minSegmentDuration,
+ minProgressThreshold,
+ segmentHelper) {
+ const startTime = state.delay;
+ const endTime = 0;
+ const segments =
+ createPathSegments(startTime, endTime, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ appendPathElement(parentEl, segments, "delay-path negative");
+}
+
+/**
+ * Render hidden progress of negative endDelay.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Object} state - State of animation.
+ * @param {Number} minSegmentDuration - Minimum segment duration.
+ * @param {Number} minProgressThreshold - Minimum progress threshold.
+ * @param {Object} segmentHelper - The object returned by getSegmentHelper.
+ */
+function renderNegativeEndDelayHiddenProgress(parentEl, state,
+ minSegmentDuration,
+ minProgressThreshold,
+ segmentHelper) {
+ const endTime = state.delay + state.iterationCount * state.duration;
+ const startTime = endTime + state.endDelay;
+ const segments =
+ createPathSegments(startTime, endTime, minSegmentDuration,
+ minProgressThreshold, segmentHelper);
+ appendPathElement(parentEl, segments, "enddelay-path negative");
+}
+
+/**
+ * Get a helper function which returns the segment coord from given time.
+ * @param {Object} state - animation state
+ * @param {Object} win - window object
+ * @return {Object} A segmentHelper object that has the following properties:
+ * - animation: The script animation used to get the progress
+ * - endTime: The end time of the animation
+ * - asOriginalBehavior: The spec is that the progress of animation is changed
+ * if the time of setCurrentTime is during the endDelay.
+ * Likewise, in case the time is less than 0.
+ * If this flag is true, we prevent the time
+ * to make the same animation behavior as the original.
+ * - getSegment: Helper function that, given a time,
+ * will calculate the progress through the dummy animation.
+ */
+function getSegmentHelper(state, win) {
+ // Create a dummy Animation timing data as the
+ // state object we're being passed in.
+ const timing = Object.assign({}, state, {
+ iterations: state.iterationCount ? state.iterationCount : Infinity
+ });
+
+ // Create a dummy Animation with the given timing.
+ const dummyAnimation =
+ new win.Animation(new win.KeyframeEffect(null, null, timing), null);
+
+ // Returns segment helper object.
+ return {
+ animation: dummyAnimation,
+ endTime: dummyAnimation.effect.getComputedTiming().endTime,
+ asOriginalBehavior: true,
+ getSegment: function (time) {
+ if (this.asOriginalBehavior) {
+ // If the given time is less than 0, returned progress is 0.
+ if (time < 0) {
+ return { x: time, y: 0 };
+ }
+ // Avoid to apply over endTime.
+ this.animation.currentTime = time < this.endTime ? time : this.endTime;
+ } else {
+ this.animation.currentTime = time;
+ }
+ const progress = this.animation.effect.getComputedTiming().progress;
+ return { x: time, y: Math.max(progress, 0) };
+ }
+ };
+}
+
+/**
+ * Create the path segments from given parameters.
+ * @param {Number} startTime - Starting time of animation.
+ * @param {Number} endTime - Ending time of animation.
+ * @param {Number} minSegmentDuration - Minimum segment duration.
+ * @param {Number} minProgressThreshold - Minimum progress threshold.
+ * @param {Object} segmentHelper - The object of getSegmentHelper.
+ * @return {Array} path segments -
+ * [{x: {Number} time, y: {Number} progress}, ...]
+ */
+function createPathSegments(startTime, endTime, minSegmentDuration,
+ minProgressThreshold, segmentHelper) {
+ // If the duration is too short, early return.
+ if (endTime - startTime < minSegmentDuration) {
+ return [segmentHelper.getSegment(startTime),
+ segmentHelper.getSegment(endTime)];
+ }
+
+ // Otherwise, start creating segments.
+ let pathSegments = [];
+
+ // Append the segment for the startTime position.
+ const startTimeSegment = segmentHelper.getSegment(startTime);
+ pathSegments.push(startTimeSegment);
+ let previousSegment = startTimeSegment;
+
+ // Split the duration in equal intervals, and iterate over them.
+ // See the definition of DURATION_RESOLUTION for more information about this.
+ const interval = (endTime - startTime) / DURATION_RESOLUTION;
+ for (let index = 1; index <= DURATION_RESOLUTION; index++) {
+ // Create a segment for this interval.
+ const currentSegment =
+ segmentHelper.getSegment(startTime + index * interval);
+
+ // If the distance between the Y coordinate (the animation's progress) of
+ // the previous segment and the Y coordinate of the current segment is too
+ // large, then recurse with a smaller duration to get more details
+ // in the graph.
+ if (Math.abs(currentSegment.y - previousSegment.y) > minProgressThreshold) {
+ // Divide the current interval (excluding start and end bounds
+ // by adding/subtracting 1ms).
+ pathSegments = pathSegments.concat(
+ createPathSegments(previousSegment.x + 1, currentSegment.x - 1,
+ minSegmentDuration, minProgressThreshold,
+ segmentHelper));
+ }
+
+ pathSegments.push(currentSegment);
+ previousSegment = currentSegment;
+ }
+
+ return pathSegments;
+}
+
+/**
+ * Append path element.
+ * @param {Element} parentEl - Parent element of this appended path element.
+ * @param {Array} pathSegments - Path segments. Please see createPathSegments.
+ * @param {String} cls - Class name.
+ * @return {Element} path element.
+ */
+function appendPathElement(parentEl, pathSegments, cls) {
+ // Create path string.
+ let path = `M${ pathSegments[0].x },0`;
+ pathSegments.forEach(pathSegment => {
+ path += ` L${ pathSegment.x },${ pathSegment.y }`;
+ });
+ path += ` L${ pathSegments[pathSegments.length - 1].x },0 Z`;
+ // Append and return the path element.
+ return createNode({
+ parent: parentEl,
+ namespace: SVG_NS,
+ nodeType: "path",
+ attributes: {
+ "d": path,
+ "class": cls,
+ "vector-effect": "non-scaling-stroke",
+ "transform": "scale(1, -1)"
+ }
+ });
+}
diff --git a/devtools/client/animationinspector/components/animation-timeline.js b/devtools/client/animationinspector/components/animation-timeline.js
new file mode 100644
index 000000000..49995d729
--- /dev/null
+++ b/devtools/client/animationinspector/components/animation-timeline.js
@@ -0,0 +1,502 @@
+/* -*- 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";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const {
+ createNode,
+ findOptimalTimeInterval,
+ TimeScale
+} = require("devtools/client/animationinspector/utils");
+const {AnimationDetails} = require("devtools/client/animationinspector/components/animation-details");
+const {AnimationTargetNode} = require("devtools/client/animationinspector/components/animation-target-node");
+const {AnimationTimeBlock} = require("devtools/client/animationinspector/components/animation-time-block");
+
+// The minimum spacing between 2 time graduation headers in the timeline (px).
+const TIME_GRADUATION_MIN_SPACING = 40;
+// When the container window is resized, the timeline background gets refreshed,
+// but only after a timer, and the timer is reset if the window is continuously
+// resized.
+const TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER = 50;
+
+/**
+ * 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.
+ * The current time play head can be moved by clicking/dragging in the header.
+ * when this happens, the component emits "current-data-changed" events with the
+ * new time and state of the timeline.
+ *
+ * @param {InspectorPanel} inspector.
+ * @param {Object} serverTraits The list of server-side capabilities.
+ */
+function AnimationsTimeline(inspector, serverTraits) {
+ this.animations = [];
+ this.targetNodes = [];
+ this.timeBlocks = [];
+ this.details = [];
+ this.inspector = inspector;
+ this.serverTraits = serverTraits;
+
+ this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this);
+ this.onScrubberMouseDown = this.onScrubberMouseDown.bind(this);
+ this.onScrubberMouseUp = this.onScrubberMouseUp.bind(this);
+ this.onScrubberMouseOut = this.onScrubberMouseOut.bind(this);
+ this.onScrubberMouseMove = this.onScrubberMouseMove.bind(this);
+ this.onAnimationSelected = this.onAnimationSelected.bind(this);
+ this.onWindowResize = this.onWindowResize.bind(this);
+ this.onFrameSelected = this.onFrameSelected.bind(this);
+
+ EventEmitter.decorate(this);
+}
+
+exports.AnimationsTimeline = AnimationsTimeline;
+
+AnimationsTimeline.prototype = {
+ init: function (containerEl) {
+ this.win = containerEl.ownerDocument.defaultView;
+
+ this.rootWrapperEl = createNode({
+ parent: containerEl,
+ attributes: {
+ "class": "animation-timeline"
+ }
+ });
+
+ let scrubberContainer = createNode({
+ parent: this.rootWrapperEl,
+ attributes: {"class": "scrubber-wrapper"}
+ });
+
+ this.scrubberEl = createNode({
+ parent: scrubberContainer,
+ attributes: {
+ "class": "scrubber"
+ }
+ });
+
+ this.scrubberHandleEl = createNode({
+ parent: this.scrubberEl,
+ attributes: {
+ "class": "scrubber-handle"
+ }
+ });
+ this.scrubberHandleEl.addEventListener("mousedown",
+ this.onScrubberMouseDown);
+
+ this.headerWrapper = createNode({
+ parent: this.rootWrapperEl,
+ attributes: {
+ "class": "header-wrapper"
+ }
+ });
+
+ this.timeHeaderEl = createNode({
+ parent: this.headerWrapper,
+ attributes: {
+ "class": "time-header track-container"
+ }
+ });
+
+ this.timeHeaderEl.addEventListener("mousedown",
+ this.onScrubberMouseDown);
+
+ this.timeTickEl = createNode({
+ parent: this.rootWrapperEl,
+ attributes: {
+ "class": "time-body track-container"
+ }
+ });
+
+ this.animationsEl = createNode({
+ parent: this.rootWrapperEl,
+ nodeType: "ul",
+ attributes: {
+ "class": "animations"
+ }
+ });
+
+ this.win.addEventListener("resize",
+ this.onWindowResize);
+ },
+
+ destroy: function () {
+ this.stopAnimatingScrubber();
+ this.unrender();
+
+ this.win.removeEventListener("resize",
+ this.onWindowResize);
+ this.timeHeaderEl.removeEventListener("mousedown",
+ this.onScrubberMouseDown);
+ this.scrubberHandleEl.removeEventListener("mousedown",
+ this.onScrubberMouseDown);
+
+ this.rootWrapperEl.remove();
+ this.animations = [];
+
+ this.rootWrapperEl = null;
+ this.timeHeaderEl = null;
+ this.animationsEl = null;
+ this.scrubberEl = null;
+ this.scrubberHandleEl = null;
+ this.win = null;
+ this.inspector = null;
+ this.serverTraits = null;
+ },
+
+ /**
+ * Destroy sub-components that have been created and stored on this instance.
+ * @param {String} name An array of components will be expected in this[name]
+ * @param {Array} handlers An option list of event handlers information that
+ * should be used to remove these handlers.
+ */
+ destroySubComponents: function (name, handlers = []) {
+ for (let component of this[name]) {
+ for (let {event, fn} of handlers) {
+ component.off(event, fn);
+ }
+ component.destroy();
+ }
+ this[name] = [];
+ },
+
+ unrender: function () {
+ for (let animation of this.animations) {
+ animation.off("changed", this.onAnimationStateChanged);
+ }
+ this.stopAnimatingScrubber();
+ TimeScale.reset();
+ this.destroySubComponents("targetNodes");
+ this.destroySubComponents("timeBlocks");
+ this.destroySubComponents("details", [{
+ event: "frame-selected",
+ fn: this.onFrameSelected
+ }]);
+ this.animationsEl.innerHTML = "";
+ },
+
+ onWindowResize: function () {
+ // Don't do anything if the root element has a width of 0
+ if (this.rootWrapperEl.offsetWidth === 0) {
+ return;
+ }
+
+ if (this.windowResizeTimer) {
+ this.win.clearTimeout(this.windowResizeTimer);
+ }
+
+ this.windowResizeTimer = this.win.setTimeout(() => {
+ this.drawHeaderAndBackground();
+ }, TIMELINE_BACKGROUND_RESIZE_DEBOUNCE_TIMER);
+ },
+
+ onAnimationSelected: function (e, animation) {
+ let index = this.animations.indexOf(animation);
+ if (index === -1) {
+ return;
+ }
+
+ let el = this.rootWrapperEl;
+ let animationEl = el.querySelectorAll(".animation")[index];
+ let propsEl = el.querySelectorAll(".animated-properties")[index];
+
+ // Toggle the selected state on this animation.
+ animationEl.classList.toggle("selected");
+ propsEl.classList.toggle("selected");
+
+ // Render the details component for this animation if it was shown.
+ if (animationEl.classList.contains("selected")) {
+ this.details[index].render(animation);
+ this.emit("animation-selected", animation);
+ } else {
+ this.emit("animation-unselected", animation);
+ }
+ },
+
+ /**
+ * When a frame gets selected, move the scrubber to the corresponding position
+ */
+ onFrameSelected: function (e, {x}) {
+ this.moveScrubberTo(x, true);
+ },
+
+ onScrubberMouseDown: function (e) {
+ this.moveScrubberTo(e.pageX);
+ 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();
+ },
+
+ onScrubberMouseUp: function () {
+ this.cancelTimeHeaderDragging();
+ },
+
+ onScrubberMouseOut: function (e) {
+ // 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 () {
+ this.win.removeEventListener("mouseup", this.onScrubberMouseUp);
+ this.win.removeEventListener("mouseout", this.onScrubberMouseOut);
+ this.win.removeEventListener("mousemove", this.onScrubberMouseMove);
+ },
+
+ onScrubberMouseMove: function (e) {
+ this.moveScrubberTo(e.pageX);
+ },
+
+ moveScrubberTo: function (pageX, noOffset) {
+ this.stopAnimatingScrubber();
+
+ // The offset needs to be in % and relative to the timeline's area (so we
+ // subtract the scrubber's left offset, which is equal to the sidebar's
+ // width).
+ let offset = pageX;
+ if (!noOffset) {
+ offset -= this.timeHeaderEl.offsetLeft;
+ }
+ offset = offset * 100 / this.timeHeaderEl.offsetWidth;
+ if (offset < 0) {
+ offset = 0;
+ }
+
+ this.scrubberEl.style.left = offset + "%";
+
+ let time = TimeScale.distanceToRelativeTime(offset);
+
+ this.emit("timeline-data-changed", {
+ isPaused: true,
+ isMoving: false,
+ isUserDrag: true,
+ time: time
+ });
+ },
+
+ getCompositorStatusClassName: function (state) {
+ let className = state.isRunningOnCompositor
+ ? " fast-track"
+ : "";
+
+ if (state.isRunningOnCompositor && state.propertyState) {
+ className +=
+ state.propertyState.some(propState => !propState.runningOnCompositor)
+ ? " some-properties"
+ : " all-properties";
+ }
+
+ return className;
+ },
+
+ render: function (animations, documentCurrentTime) {
+ 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 " +
+ animation.state.type +
+ this.getCompositorStatusClassName(animation.state)
+ }
+ });
+
+ // Right below the line is a hidden-by-default line for displaying the
+ // inline keyframes.
+ let detailsEl = createNode({
+ parent: this.animationsEl,
+ nodeType: "li",
+ attributes: {
+ "class": "animated-properties " + animation.state.type
+ }
+ });
+
+ let details = new AnimationDetails(this.serverTraits);
+ details.init(detailsEl);
+ details.on("frame-selected", this.onFrameSelected);
+ this.details.push(details);
+
+ // Left sidebar for the animated node.
+ let animatedNodeEl = createNode({
+ parent: animationEl,
+ attributes: {
+ "class": "target"
+ }
+ });
+
+ // Draw the animated node target.
+ let targetNode = new AnimationTargetNode(this.inspector, {compact: true});
+ targetNode.init(animatedNodeEl);
+ targetNode.render(animation);
+ this.targetNodes.push(targetNode);
+
+ // Right-hand part contains the timeline itself (called time-block here).
+ let timeBlockEl = createNode({
+ parent: animationEl,
+ attributes: {
+ "class": "time-block track-container"
+ }
+ });
+
+ // Draw the animation time block.
+ let timeBlock = new AnimationTimeBlock();
+ timeBlock.init(timeBlockEl);
+ timeBlock.render(animation);
+ this.timeBlocks.push(timeBlock);
+
+ timeBlock.on("selected", this.onAnimationSelected);
+ }
+
+ // 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.
+ if (!documentCurrentTime) {
+ this.scrubberEl.style.display = "none";
+ } else {
+ this.scrubberEl.style.display = "block";
+ this.startAnimatingScrubber(this.wasRewound()
+ ? TimeScale.minStartTime
+ : documentCurrentTime);
+ }
+ },
+
+ isAtLeastOneAnimationPlaying: function () {
+ return this.animations.some(({state}) => state.playState === "running");
+ },
+
+ wasRewound: function () {
+ return !this.isAtLeastOneAnimationPlaying() &&
+ this.animations.every(({state}) => state.currentTime === 0);
+ },
+
+ hasInfiniteAnimations: function () {
+ return this.animations.some(({state}) => !state.iterationCount);
+ },
+
+ startAnimatingScrubber: function (time) {
+ let isOutOfBounds = time < TimeScale.minStartTime ||
+ time > TimeScale.maxEndTime;
+ let isAllPaused = !this.isAtLeastOneAnimationPlaying();
+ let hasInfinite = this.hasInfiniteAnimations();
+
+ let x = TimeScale.startTimeToDistance(time);
+ if (x > 100 && !hasInfinite) {
+ x = 100;
+ }
+ this.scrubberEl.style.left = x + "%";
+
+ // 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.
+ if (isAllPaused || (isOutOfBounds && !hasInfinite)) {
+ this.stopAnimatingScrubber();
+ this.emit("timeline-data-changed", {
+ isPaused: !this.isAtLeastOneAnimationPlaying(),
+ isMoving: false,
+ isUserDrag: false,
+ time: TimeScale.distanceToRelativeTime(x)
+ });
+ return;
+ }
+
+ this.emit("timeline-data-changed", {
+ isPaused: false,
+ isMoving: true,
+ isUserDrag: false,
+ time: TimeScale.distanceToRelativeTime(x)
+ });
+
+ let now = this.win.performance.now();
+ this.rafID = this.win.requestAnimationFrame(() => {
+ if (!this.rafID) {
+ // In case the scrubber was stopped in the meantime.
+ return;
+ }
+ this.startAnimatingScrubber(time + this.win.performance.now() - now);
+ });
+ },
+
+ stopAnimatingScrubber: function () {
+ if (this.rafID) {
+ this.win.cancelAnimationFrame(this.rafID);
+ this.rafID = null;
+ }
+ },
+
+ 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 animationDuration = TimeScale.maxEndTime - TimeScale.minStartTime;
+ let minTimeInterval = TIME_GRADUATION_MIN_SPACING *
+ animationDuration / width;
+ let intervalLength = findOptimalTimeInterval(minTimeInterval);
+ let intervalWidth = intervalLength * width / animationDuration;
+
+ // And the time graduation header.
+ this.timeHeaderEl.innerHTML = "";
+ this.timeTickEl.innerHTML = "";
+
+ for (let i = 0; i <= width / intervalWidth; i++) {
+ let pos = 100 * i * intervalWidth / width;
+
+ // This element is the header of time tick for displaying animation
+ // duration time.
+ createNode({
+ parent: this.timeHeaderEl,
+ nodeType: "span",
+ attributes: {
+ "class": "header-item",
+ "style": `left:${pos}%`
+ },
+ textContent: TimeScale.formatTime(TimeScale.distanceToRelativeTime(pos))
+ });
+
+ // This element is displayed as a vertical line separator corresponding
+ // the header of time tick for indicating time slice for animation
+ // iterations.
+ createNode({
+ parent: this.timeTickEl,
+ nodeType: "span",
+ attributes: {
+ "class": "time-tick",
+ "style": `left:${pos}%`
+ }
+ });
+ }
+ }
+};
diff --git a/devtools/client/animationinspector/components/keyframes.js b/devtools/client/animationinspector/components/keyframes.js
new file mode 100644
index 000000000..a017935a3
--- /dev/null
+++ b/devtools/client/animationinspector/components/keyframes.js
@@ -0,0 +1,81 @@
+/* -*- 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";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const {createNode} = require("devtools/client/animationinspector/utils");
+
+/**
+ * UI component responsible for displaying a list of keyframes.
+ */
+function Keyframes() {
+ EventEmitter.decorate(this);
+ this.onClick = this.onClick.bind(this);
+}
+
+exports.Keyframes = Keyframes;
+
+Keyframes.prototype = {
+ init: function (containerEl) {
+ this.containerEl = containerEl;
+
+ this.keyframesEl = createNode({
+ parent: this.containerEl,
+ attributes: {"class": "keyframes"}
+ });
+
+ this.containerEl.addEventListener("click", this.onClick);
+ },
+
+ destroy: function () {
+ this.containerEl.removeEventListener("click", this.onClick);
+ this.keyframesEl.remove();
+ this.containerEl = this.keyframesEl = this.animation = null;
+ },
+
+ render: function ({keyframes, propertyName, animation}) {
+ this.keyframes = keyframes;
+ this.propertyName = propertyName;
+ this.animation = animation;
+
+ let iterationStartOffset =
+ animation.state.iterationStart % 1 == 0
+ ? 0
+ : 1 - animation.state.iterationStart % 1;
+
+ this.keyframesEl.classList.add(animation.state.type);
+ for (let frame of this.keyframes) {
+ let offset = frame.offset + iterationStartOffset;
+ createNode({
+ parent: this.keyframesEl,
+ attributes: {
+ "class": "frame",
+ "style": `left:${offset * 100}%;`,
+ "data-offset": frame.offset,
+ "data-property": propertyName,
+ "title": frame.value
+ }
+ });
+ }
+ },
+
+ onClick: function (e) {
+ // If the click happened on a frame, tell our parent about it.
+ if (!e.target.classList.contains("frame")) {
+ return;
+ }
+
+ e.stopPropagation();
+ this.emit("frame-selected", {
+ animation: this.animation,
+ propertyName: this.propertyName,
+ offset: parseFloat(e.target.dataset.offset),
+ value: e.target.getAttribute("title"),
+ x: e.target.offsetLeft + e.target.closest(".frames").offsetLeft
+ });
+ }
+};
diff --git a/devtools/client/animationinspector/components/moz.build b/devtools/client/animationinspector/components/moz.build
new file mode 100644
index 000000000..2265f8c28
--- /dev/null
+++ b/devtools/client/animationinspector/components/moz.build
@@ -0,0 +1,12 @@
+# 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/.
+
+DevToolsModules(
+ 'animation-details.js',
+ 'animation-target-node.js',
+ 'animation-time-block.js',
+ 'animation-timeline.js',
+ 'keyframes.js',
+ 'rate-selector.js'
+)
diff --git a/devtools/client/animationinspector/components/rate-selector.js b/devtools/client/animationinspector/components/rate-selector.js
new file mode 100644
index 000000000..e46664e6a
--- /dev/null
+++ b/devtools/client/animationinspector/components/rate-selector.js
@@ -0,0 +1,105 @@
+/* -*- 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";
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const {createNode} = require("devtools/client/animationinspector/utils");
+const { LocalizationHelper } = require("devtools/shared/l10n");
+const L10N =
+ new LocalizationHelper("devtools/client/locales/animationinspector.properties");
+
+// List of playback rate presets displayed in the timeline toolbar.
+const PLAYBACK_RATES = [.1, .25, .5, 1, 2, 5, 10];
+
+/**
+ * UI component responsible for displaying a playback rate selector UI.
+ * The rendering logic is such that a predefined list of rates is generated.
+ * If *all* animations passed to render share the same rate, then that rate is
+ * selected in the <select> element, otherwise, the empty value is selected.
+ * If the rate that all animations share isn't part of the list of predefined
+ * rates, than that rate is added to the list.
+ */
+function RateSelector() {
+ this.onRateChanged = this.onRateChanged.bind(this);
+ EventEmitter.decorate(this);
+}
+
+exports.RateSelector = RateSelector;
+
+RateSelector.prototype = {
+ init: function (containerEl) {
+ this.selectEl = createNode({
+ parent: containerEl,
+ nodeType: "select",
+ attributes: {
+ "class": "devtools-button",
+ "title": L10N.getStr("timeline.rateSelectorTooltip")
+ }
+ });
+
+ this.selectEl.addEventListener("change", this.onRateChanged);
+ },
+
+ destroy: function () {
+ this.selectEl.removeEventListener("change", this.onRateChanged);
+ this.selectEl.remove();
+ this.selectEl = null;
+ },
+
+ getAnimationsRates: function (animations) {
+ return sortedUnique(animations.map(a => a.state.playbackRate));
+ },
+
+ getAllRates: function (animations) {
+ let animationsRates = this.getAnimationsRates(animations);
+ if (animationsRates.length > 1) {
+ return PLAYBACK_RATES;
+ }
+
+ return sortedUnique(PLAYBACK_RATES.concat(animationsRates));
+ },
+
+ render: function (animations) {
+ let allRates = this.getAnimationsRates(animations);
+ let hasOneRate = allRates.length === 1;
+
+ this.selectEl.innerHTML = "";
+
+ if (!hasOneRate) {
+ // When the animations displayed have mixed playback rates, we can't
+ // select any of the predefined ones, instead, insert an empty rate.
+ createNode({
+ parent: this.selectEl,
+ nodeType: "option",
+ attributes: {value: "", selector: "true"},
+ textContent: "-"
+ });
+ }
+ for (let rate of this.getAllRates(animations)) {
+ let option = createNode({
+ parent: this.selectEl,
+ nodeType: "option",
+ attributes: {value: rate},
+ textContent: L10N.getFormatStr("player.playbackRateLabel", rate)
+ });
+
+ // If there's only one rate and this is the option for it, select it.
+ if (hasOneRate && rate === allRates[0]) {
+ option.setAttribute("selected", "true");
+ }
+ }
+ },
+
+ onRateChanged: function () {
+ let rate = parseFloat(this.selectEl.value);
+ if (!isNaN(rate)) {
+ this.emit("rate-changed", rate);
+ }
+ }
+};
+
+let sortedUnique = arr => [...new Set(arr)].sort((a, b) => a > b);