Bug 1210796 - Part 6: Fixed animation detail panel. r=pbro draft
authorDaisuke Akatsuka <daisuke@mozilla-japan.org>
Tue, 18 Apr 2017 12:15:55 +0900
changeset 564119 f7d9f699f73aadbd48f9f1726dc397386b172c66
parent 564118 f76962e7b3f29ab5f535e5921971e817db0a1995
child 564120 506b53318e6668f9cc8bcda8cae2a4923e193efc
push id54524
push userbmo:dakatsuka@mozilla.com
push dateTue, 18 Apr 2017 09:24:06 +0000
reviewerspbro
bugs1210796
milestone55.0a1
Bug 1210796 - Part 6: Fixed animation detail panel. r=pbro MozReview-Commit-ID: CYIka7UkTPx
devtools/client/animationinspector/animation-inspector.xhtml
devtools/client/animationinspector/components/animation-details.js
devtools/client/animationinspector/components/animation-time-block.js
devtools/client/animationinspector/components/animation-timeline.js
devtools/client/animationinspector/test/browser_animation_animated_properties_displayed.js
devtools/client/animationinspector/test/browser_animation_click_selects_animation.js
devtools/client/animationinspector/test/browser_animation_keyframe_click_to_set_time.js
devtools/client/animationinspector/test/browser_animation_timeline_iterationStart.js
devtools/client/animationinspector/test/head.js
devtools/client/animationinspector/utils.js
devtools/client/locales/en-US/animationinspector.properties
devtools/client/themes/animationinspector.css
--- a/devtools/client/animationinspector/animation-inspector.xhtml
+++ b/devtools/client/animationinspector/animation-inspector.xhtml
@@ -2,16 +2,17 @@
 <!-- This Source Code Form is subject to the terms of the Mozilla Public
    - License, v. 2.0. If a copy of the MPL was not distributed with this
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 <!DOCTYPE html>
 <html xmlns="http://www.w3.org/1999/xhtml">
   <head>
     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
     <link rel="stylesheet" href="chrome://devtools/skin/animationinspector.css" type="text/css"/>
+    <link rel="stylesheet" href="resource://devtools/client/shared/components/splitter/split-box.css"/>
     <script type="application/javascript" src="chrome://devtools/content/shared/theme-switching.js"/>
   </head>
   <body class="theme-sidebar devtools-monospace" role="application" empty="true">
     <div id="global-toolbar" class="theme-toolbar">
       <span id="all-animations-label" class="label"></span>
       <button id="toggle-all" class="devtools-button pause-button"></button>
     </div>
     <div id="timeline-toolbar" class="theme-toolbar">
@@ -21,12 +22,22 @@
       <span id="timeline-current-time" class="label"></span>
     </div>
     <div id="players"></div>
     <div id="error-message">
       <p id="error-type"></p>
       <p id="error-hint"></p>
       <button id="element-picker" data-standalone="true" class="devtools-button"></button>
     </div>
+    <script type="text/javascript">
+      /* eslint-disable */
+      var isInChrome = window.location.href.includes("chrome:");
+      if (isInChrome) {
+        var exports = {};
+        var Cu = Components.utils;
+        var { require } = Cu.import("resource://devtools/shared/Loader.jsm", {});
+        var { BrowserLoader } = Cu.import("resource://devtools/client/shared/browser-loader.js", {});
+      }
+    </script>
     <script type="application/javascript" src="animation-controller.js"></script>
     <script type="application/javascript" src="animation-panel.js"></script>
   </body>
 </html>
--- a/devtools/client/animationinspector/components/animation-details.js
+++ b/devtools/client/animationinspector/components/animation-details.js
@@ -200,22 +200,22 @@ AnimationDetails.prototype = {
     // Relay the event up, it's needed in parents too.
     this.emit(e, args);
   },
 
   renderAnimatedPropertiesHeader: function () {
     // Add animated property header.
     const headerEl = createNode({
       parent: this.containerEl,
-      attributes: { "class": "animated-properties-header property" }
+      attributes: { "class": "animated-properties-header" }
     });
 
     // Add progress tick container.
     const progressTickContainerEl = createNode({
-      parent: headerEl,
+      parent: this.containerEl,
       attributes: { "class": "progress-tick-container track-container" }
     });
 
     // Add label container.
     const headerLabelContainerEl = createNode({
       parent: headerEl,
       attributes: { "class": "track-container" }
     });
--- a/devtools/client/animationinspector/components/animation-time-block.js
+++ b/devtools/client/animationinspector/components/animation-time-block.js
@@ -2,17 +2,18 @@
 /* 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 {createNode, TimeScale, getFormattedAnimationTitle} =
+  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
@@ -350,40 +351,16 @@ AnimationTimeBlock.prototype = {
   },
 
   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 };
--- a/devtools/client/animationinspector/components/animation-timeline.js
+++ b/devtools/client/animationinspector/components/animation-timeline.js
@@ -5,22 +5,27 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const EventEmitter = require("devtools/shared/event-emitter");
 const {
   createNode,
   findOptimalTimeInterval,
+  getFormattedAnimationTitle,
   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");
 
+const { LocalizationHelper } = require("devtools/shared/l10n");
+const L10N =
+  new LocalizationHelper("devtools/client/locales/animationinspector.properties");
+
 // 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;
 
 /**
@@ -37,17 +42,16 @@ const TIMELINE_BACKGROUND_RESIZE_DEBOUNC
  *
  * @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);
@@ -58,103 +62,193 @@ function AnimationsTimeline(inspector, s
   EventEmitter.decorate(this);
 }
 
 exports.AnimationsTimeline = AnimationsTimeline;
 
 AnimationsTimeline.prototype = {
   init: function (containerEl) {
     this.win = containerEl.ownerDocument.defaultView;
+    this.rootWrapperEl = containerEl;
 
-    this.rootWrapperEl = createNode({
-      parent: containerEl,
-      attributes: {
-        "class": "animation-timeline"
-      }
+    this.setupSplitBox();
+    this.setupAnimationTimeline();
+    this.setupAnimationDetail();
+
+    this.win.addEventListener("resize",
+      this.onWindowResize);
+  },
+
+  setupSplitBox: function () {
+    const browserRequire = this.win.BrowserLoader({
+      window: this.win,
+      useOnlyShared: true
+    }).require;
+
+    const React = browserRequire("devtools/client/shared/vendor/react");
+    const ReactDOM = browserRequire("devtools/client/shared/vendor/react-dom");
+
+    const SplitBox = React.createFactory(
+      browserRequire("devtools/client/shared/components/splitter/split-box"));
+
+    const splitter = SplitBox({
+      className: "animation-root",
+      initialSize: "0 0",
+      maxSize: "calc(100% - (var(--timeline-animation-height) * 2))",
+      splitterSize: 1,
+      endPanelControl: true,
+      startPanel: React.DOM.div({
+        className: "animation-timeline"
+      }),
+      endPanel: React.DOM.div({
+        className: "animation-detail"
+      }),
+      vert: false
     });
 
+    ReactDOM.render(splitter, this.rootWrapperEl);
+  },
+
+  setupAnimationTimeline: function () {
+    const animationTimelineEl = this.rootWrapperEl.querySelector(".animation-timeline");
+
     let scrubberContainer = createNode({
-      parent: this.rootWrapperEl,
+      parent: animationTimelineEl,
       attributes: {"class": "scrubber-wrapper"}
     });
 
     this.scrubberEl = createNode({
       parent: scrubberContainer,
       attributes: {
         "class": "scrubber"
       }
     });
 
     this.scrubberHandleEl = createNode({
       parent: this.scrubberEl,
       attributes: {
         "class": "scrubber-handle"
       }
     });
+    createNode({
+      parent: this.scrubberHandleEl,
+      attributes: {
+        "class": "scrubber-line"
+      }
+    });
     this.scrubberHandleEl.addEventListener("mousedown",
-      this.onScrubberMouseDown);
+                                           this.onScrubberMouseDown);
 
     this.headerWrapper = createNode({
-      parent: this.rootWrapperEl,
+      parent: animationTimelineEl,
       attributes: {
         "class": "header-wrapper"
       }
     });
 
     this.timeHeaderEl = createNode({
       parent: this.headerWrapper,
       attributes: {
         "class": "time-header track-container"
       }
     });
 
     this.timeHeaderEl.addEventListener("mousedown",
-      this.onScrubberMouseDown);
+                                       this.onScrubberMouseDown);
 
     this.timeTickEl = createNode({
-      parent: this.rootWrapperEl,
+      parent: animationTimelineEl,
       attributes: {
         "class": "time-body track-container"
       }
     });
 
     this.animationsEl = createNode({
-      parent: this.rootWrapperEl,
+      parent: animationTimelineEl,
       nodeType: "ul",
       attributes: {
         "class": "animations"
       }
     });
+  },
 
-    this.win.addEventListener("resize",
-      this.onWindowResize);
+  setupAnimationDetail: function () {
+    this.animationDetailEl = this.rootWrapperEl.querySelector(".animation-detail");
+
+    this.animationDetailEl.dataset.defaultDisplayStyle =
+      this.win.getComputedStyle(this.animationDetailEl).display;
+    this.animationDetailEl.style.display = "none";
+
+    const animationDetailHeaderEl = createNode({
+      parent: this.animationDetailEl,
+      attributes: {
+        "class": "animation-detail-header"
+      }
+    });
+
+    const headerTitleEl = createNode({
+      parent: animationDetailHeaderEl,
+      attributes: {
+        "class": "devtools-toolbar"
+      }
+    });
+
+    createNode({
+      parent: headerTitleEl,
+      textContent: L10N.getStr("detail.headerTitle")
+    });
+
+    this.animationAnimationNameEl = createNode({
+      parent: headerTitleEl
+    });
+
+    const animationDetailBodyEl = createNode({
+      parent: this.animationDetailEl,
+      attributes: {
+        "class": "animation-detail-body"
+      }
+    });
+
+    this.animatedPropertiesEl = createNode({
+      parent: animationDetailBodyEl,
+      attributes: {
+        "class": "animated-properties"
+      }
+    });
+
+    this.details = new AnimationDetails(this.serverTraits);
+    this.details.init(this.animatedPropertiesEl);
   },
 
   destroy: function () {
     this.stopAnimatingScrubber();
     this.unrender();
+    this.details.destroy();
 
     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.animatedPropertiesEl = null;
     this.scrubberEl = null;
     this.scrubberHandleEl = null;
     this.win = null;
     this.inspector = null;
     this.serverTraits = null;
+    this.animationDetailEl = null;
+    this.animationAnimationNameEl = null;
+    this.animatedPropertiesEl = 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.
    */
@@ -171,20 +265,18 @@ AnimationsTimeline.prototype = {
   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.details.off("frame-selected", this.onFrameSelected);
+    this.details.unrender();
     this.animationsEl.innerHTML = "";
   },
 
   onWindowResize: function () {
     // Don't do anything if the root element has a width of 0
     if (this.rootWrapperEl.offsetWidth === 0) {
       return;
     }
@@ -201,28 +293,37 @@ AnimationsTimeline.prototype = {
   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);
+      // Add class of animation type.
+      if (!this.animatedPropertiesEl.classList.contains(animation.state.type)) {
+        this.animatedPropertiesEl.className =
+          `animated-properties ${ animation.state.type }`;
+      }
+      this.animationDetailEl.style.display =
+        this.animationDetailEl.dataset.defaultDisplayStyle;
+      this.details.render(animation);
       this.emit("animation-selected", animation);
+
+      this.animationAnimationNameEl.textContent =
+        getFormattedAnimationTitle(animation);
     } else {
       this.emit("animation-unselected", animation);
+      this.animationDetailEl.style.display = "none";
     }
   },
 
   /**
    * When a frame gets selected, move the scrubber to the corresponding position
    */
   onFrameSelected: function (e, {x}) {
     this.moveScrubberTo(x, true);
@@ -326,31 +427,16 @@ AnimationsTimeline.prototype = {
         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"
         }
       });
 
@@ -371,16 +457,17 @@ AnimationsTimeline.prototype = {
       // Draw the animation time block.
       let timeBlock = new AnimationTimeBlock();
       timeBlock.init(timeBlockEl);
       timeBlock.render(animation);
       this.timeBlocks.push(timeBlock);
 
       timeBlock.on("selected", this.onAnimationSelected);
     }
+    this.details.on("frame-selected", this.onFrameSelected);
 
     // 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 {
--- a/devtools/client/animationinspector/test/browser_animation_animated_properties_displayed.js
+++ b/devtools/client/animationinspector/test/browser_animation_animated_properties_displayed.js
@@ -47,22 +47,16 @@ add_task(function* () {
      "The list of properties panel is shown");
   ok(propertiesList.querySelectorAll(".property").length,
      "The list of properties panel actually contains properties");
   ok(hasExpectedProperties(propertiesList),
      "The list of properties panel contains the right properties");
 
   ok(hasExpectedWarnings(propertiesList),
      "The list of properties panel contains the right warnings");
-
-  info("Click to unselect the animation");
-  yield clickOnAnimation(panel, 0, true);
-
-  ok(!isNodeVisible(propertiesList),
-     "The list of properties panel is hidden again");
 });
 
 function hasExpectedProperties(containerEl) {
   let names = [...containerEl.querySelectorAll(".property .name")]
               .map(n => n.textContent)
               .sort();
 
   if (names.length !== EXPECTED_PROPERTIES.length) {
--- a/devtools/client/animationinspector/test/browser_animation_click_selects_animation.js
+++ b/devtools/client/animationinspector/test/browser_animation_click_selects_animation.js
@@ -32,13 +32,10 @@ add_task(function* () {
   info("Click again on the first animation and check if it unselects");
   yield clickOnAnimation(panel, 0, true);
   ok(!isTimeBlockSelected(timeline, 0),
      "The first time block has been unselected");
 });
 
 function isTimeBlockSelected(timeline, index) {
   let animation = timeline.rootWrapperEl.querySelectorAll(".animation")[index];
-  let animatedProperties = timeline.rootWrapperEl.querySelectorAll(
-    ".animated-properties")[index];
-  return animation.classList.contains("selected") &&
-         animatedProperties.classList.contains("selected");
+  return animation.classList.contains("selected");
 }
--- a/devtools/client/animationinspector/test/browser_animation_keyframe_click_to_set_time.js
+++ b/devtools/client/animationinspector/test/browser_animation_keyframe_click_to_set_time.js
@@ -17,32 +17,32 @@ add_task(function* () {
   // the animations to be slightly offset with the header when it appears.
   // So for now, let's hide the scrollbar. Bug 1229340 should fix this.
   timeline.animationsEl.style.overflow = "hidden";
 
   info("Expand the animation");
   yield clickOnAnimation(panel, 0);
 
   info("Click on the first keyframe of the first animated property");
-  yield clickKeyframe(panel, 0, "background-color", 0);
+  yield clickKeyframe(panel, "background-color", 0);
 
   info("Make sure the scrubber stopped moving and is at the right position");
   yield assertScrubberMoving(panel, false);
   checkScrubberPos(scrubberEl, 0);
 
   info("Click on a keyframe in the middle");
-  yield clickKeyframe(panel, 0, "transform", 2);
+  yield clickKeyframe(panel, "transform", 2);
 
   info("Make sure the scrubber is at the right position");
   checkScrubberPos(scrubberEl, 50);
 });
 
-function* clickKeyframe(panel, animIndex, property, index) {
-  let keyframeComponent = getKeyframeComponent(panel, animIndex, property);
-  let keyframeEl = getKeyframeEl(panel, animIndex, property, index);
+function* clickKeyframe(panel, property, index) {
+  let keyframeComponent = getKeyframeComponent(panel, property);
+  let keyframeEl = getKeyframeEl(panel, property, index);
 
   let onSelect = keyframeComponent.once("frame-selected");
   EventUtils.sendMouseEvent({type: "click"}, keyframeEl,
                             keyframeEl.ownerDocument.defaultView);
   yield onSelect;
 }
 
 function checkScrubberPos(scrubberEl, pos) {
--- a/devtools/client/animationinspector/test/browser_animation_timeline_iterationStart.js
+++ b/devtools/client/animationinspector/test/browser_animation_timeline_iterationStart.js
@@ -6,32 +6,32 @@
 
 // Check that the iteration start is displayed correctly in time blocks.
 
 add_task(function* () {
   yield addTab(URL_ROOT + "doc_script_animation.html");
   let {panel} = yield openAnimationInspector();
   let timelineComponent = panel.animationsTimelineComponent;
   let timeBlockComponents = timelineComponent.timeBlocks;
-  let detailsComponents = timelineComponent.details;
+  let detailsComponent = timelineComponent.details;
 
   for (let i = 0; i < timeBlockComponents.length; i++) {
     info(`Expand time block ${i} so its keyframes are visible`);
     yield clickOnAnimation(panel, i);
 
     info(`Check the state of time block ${i}`);
     let {containerEl, animation: {state}} = timeBlockComponents[i];
 
     checkAnimationTooltip(containerEl, state);
     checkProgressAtStartingTime(containerEl, state);
 
     // Get the first set of keyframes (there's only one animated property
     // anyway), and the first frame element from there, we're only interested in
     // its offset.
-    let keyframeComponent = detailsComponents[i].keyframeComponents[0];
+    let keyframeComponent = detailsComponent.keyframeComponents[0];
     let frameEl = keyframeComponent.keyframesEl.querySelector(".frame");
     checkKeyframeOffset(containerEl, frameEl, state);
   }
 });
 
 function checkAnimationTooltip(el, {iterationStart, duration}) {
   info("Check an animation's iterationStart data in its tooltip");
   let title = el.querySelector(".name").getAttribute("title");
--- a/devtools/client/animationinspector/test/head.js
+++ b/devtools/client/animationinspector/test/head.js
@@ -380,47 +380,44 @@ function* clickOnAnimation(panel, index,
   let onSelectionChanged = timeline.once(shouldClose
                                          ? "animation-unselected"
                                          : "animation-selected");
 
   // If we're opening the animation, also wait for
   // the animation-detail-rendering-completed event.
   let onReady = shouldClose
                 ? Promise.resolve()
-                : timeline.details[index].once("animation-detail-rendering-completed");
+                : timeline.details.once("animation-detail-rendering-completed");
 
   info("Click on animation " + index + " in the timeline");
   let timeBlock = timeline.rootWrapperEl.querySelectorAll(".time-block")[index];
   EventUtils.sendMouseEvent({type: "click"}, timeBlock,
                             timeBlock.ownerDocument.defaultView);
 
   yield onReady;
   return yield onSelectionChanged;
 }
 
 /**
  * Get an instance of the Keyframes component from the timeline.
  * @param {AnimationsPanel} panel The panel instance.
- * @param {Number} animationIndex The index of the animation in the timeline.
  * @param {String} propertyName The name of the animated property.
  * @return {Keyframes} The Keyframes component instance.
  */
-function getKeyframeComponent(panel, animationIndex, propertyName) {
+function getKeyframeComponent(panel, propertyName) {
   let timeline = panel.animationsTimelineComponent;
-  let detailsComponent = timeline.details[animationIndex];
+  let detailsComponent = timeline.details;
   return detailsComponent.keyframeComponents
                          .find(c => c.propertyName === propertyName);
 }
 
 /**
  * Get a keyframe element from the timeline.
  * @param {AnimationsPanel} panel The panel instance.
- * @param {Number} animationIndex The index of the animation in the timeline.
  * @param {String} propertyName The name of the animated property.
  * @param {Index} keyframeIndex The index of the keyframe.
  * @return {DOMNode} The keyframe element.
  */
-function getKeyframeEl(panel, animationIndex, propertyName, keyframeIndex) {
-  let keyframeComponent = getKeyframeComponent(panel, animationIndex,
-                                               propertyName);
+function getKeyframeEl(panel, propertyName, keyframeIndex) {
+  let keyframeComponent = getKeyframeComponent(panel, propertyName);
   return keyframeComponent.keyframesEl
                           .querySelectorAll(".frame")[keyframeIndex];
 }
--- a/devtools/client/animationinspector/utils.js
+++ b/devtools/client/animationinspector/utils.js
@@ -303,8 +303,33 @@ function getJsPropertyName(cssPropertyNa
     return "cssFloat";
   }
   // https://drafts.csswg.org/cssom/#css-property-to-idl-attribute
   return cssPropertyName.replace(/-([a-z])/gi, (str, group) => {
     return group.toUpperCase();
   });
 }
 exports.getJsPropertyName = getJsPropertyName;
+
+/**
+ * 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);
+}
+exports.getFormattedAnimationTitle = getFormattedAnimationTitle;
--- a/devtools/client/locales/en-US/animationinspector.properties
+++ b/devtools/client/locales/en-US/animationinspector.properties
@@ -173,8 +173,12 @@ timeline.scriptanimation.unnamedLabel=Sc
 # %S will be replaced by the name of the transition at run-time.
 timeline.unknown.nameLabel=%S
 
 # LOCALIZATION NOTE (detail.propertiesHeader.percentage):
 # This string is displayed on header label in .animated-properties-header.
 # %S represents the value in percentage with two decimal points, localized.
 # there are two "%" after %S to escape and display "%"
 detail.propertiesHeader.percentage=%S%%
+
+# LOCALIZATION NOTE (detail.headerTitle):
+# This string is displayed on header label in .animation-detail-header.
+detail.headerTitle=Animated properties for
--- a/devtools/client/themes/animationinspector.css
+++ b/devtools/client/themes/animationinspector.css
@@ -134,18 +134,16 @@ body {
 
 #sidebar-panel-animationinspector {
   height: 100%;
   width: 100%;
 }
 
 #players {
   height: calc(100% - var(--toolbar-height));
-  overflow-x: hidden;
-  overflow-y: auto;
 }
 
 [empty] #players {
   display: none;
 }
 
 /* The error message, shown when an invalid/unanimated element is selected */
 
@@ -225,79 +223,86 @@ body {
   padding-right: 1em;
 }
 
 #timeline-rate {
   position: relative;
   width: 4.5em;
 }
 
+.animation-root > .uncontrolled {
+  overflow: hidden;
+}
+
 /* Animation timeline component */
 
 .animation-timeline {
   position: relative;
-  display: flex;
-  flex-direction: column;
+  width: 100%;
+  overflow: auto;
 }
 
 /* Useful for positioning animations or keyframes in the timeline */
+.animation-detail .track-container,
 .animation-timeline .track-container {
   position: absolute;
   top: 0;
   left: var(--timeline-sidebar-width);
   /* Leave the width of a marker right of a track so the 100% markers can be
      selected easily */
   right: var(--keyframes-marker-size);
   height: var(--timeline-animation-height);
 }
 
 .animation-timeline .scrubber-wrapper {
   position: absolute;
+  z-index: 5;
   left: var(--timeline-sidebar-width);
   /* Leave the width of a marker right of a track so the 100% markers can be
      selected easily */
   right: var(--keyframes-marker-size);
-  height: 100%;
+  pointer-events: none;
 }
 
 .animation-timeline .scrubber {
-  z-index: 5;
   pointer-events: none;
   position: absolute;
-  /* Make the scrubber as tall as the viewport minus the toolbar height and the
-     header-wrapper's borders */
-  height: calc(100vh - var(--toolbar-height) - 1px);
-  min-height: 100%;
   width: 0;
-  border-right: 1px solid red;
-  box-sizing: border-box;
+  margin-left: -6px;
 }
 
 /* The scrubber handle is a transparent element displayed on top of the scrubber
    line that allows users to drag it */
 .animation-timeline .scrubber .scrubber-handle {
-  position: absolute;
+  position: fixed;
   height: 100%;
   /* Make it thick enough for easy dragging */
-  width: 6px;
-  right: -1.5px;
+  width: 12px;
   cursor: col-resize;
   pointer-events: all;
 }
 
 .animation-timeline .scrubber .scrubber-handle::before {
   content: "";
-  position: sticky;
+  position: absolute;
   top: 0;
   width: 1px;
   border-top: 5px solid red;
   border-left: 5px solid transparent;
   border-right: 5px solid transparent;
 }
 
+.animation-timeline .scrubber .scrubber-handle .scrubber-line {
+  position: relative;
+  height: 100%;
+  left: 5px;
+  width: 0;
+  border-right: 1px solid red;
+}
+
 .animation-timeline .time-header {
   min-height: var(--timeline-animation-height);
   cursor: col-resize;
   -moz-user-select: none;
 }
 
 .animated-properties-header .header-item,
 .animation-timeline .time-header .header-item {
@@ -309,59 +314,65 @@ body {
 
 .animation-timeline .header-wrapper {
   position: sticky;
   top: 0;
   background-color: var(--theme-body-background);
   border-bottom: 1px solid var(--time-graduation-border-color);
   z-index: 3;
   height: var(--timeline-animation-height);
+  width: 100%;
   overflow: hidden;
 }
 
 .animation-timeline .time-body {
-  height: 100%;
+  top: var(--timeline-animation-height);
 }
 
 .progress-tick-container .progress-tick,
 .animation-timeline .time-body .time-tick {
   -moz-user-select: none;
   position: absolute;
+  height: 100%;
+}
+
+.progress-tick-container .progress-tick::before,
+.animation-timeline .time-body .time-tick::before {
+  content: "";
+  position: fixed;
+  height: 100vh;
   width: 0;
-  /* When scroll bar is shown, make it covers entire time-body */
-  height: 100%;
-  /* When scroll bar is hidden, make it as tall as the viewport minus the
-     timeline animation height and the header-wrapper's borders */
-  min-height: calc(100vh - var(--timeline-animation-height) - 1px);
   border-left: 0.5px solid var(--time-graduation-border-color);
 }
 
 .animation-timeline .animations {
+  position: relative;
   width: 100%;
-  height: 100%;
   padding: 0;
   list-style-type: none;
   margin-top: 0;
 }
 
 /* Animation block widgets */
 
 .animation-timeline .animation {
   margin: 2px 0;
   height: var(--timeline-animation-height);
   position: relative;
 }
 
-/* We want animations' background colors to alternate, but each animation has
-   a sibling (hidden by default) that contains the animated properties and
-   keyframes, so we need to alternate every 4 elements. */
-.animation-timeline .animation:nth-child(4n+1) {
+/* Display animations' background colors to alternate. */
+.animation-timeline .animation:nth-child(2n+1) {
   background-color: var(--even-animation-timeline-background-color);
 }
 
+.animation-timeline .animation:last-child {
+  margin-bottom: calc(var(--timeline-animation-height) / 2);
+}
+
 .animation-timeline .animation .target {
   width: var(--timeline-sidebar-width);
   height: 100%;
   overflow: hidden;
   display: flex;
   align-items: center;
 }
 
@@ -482,17 +493,16 @@ body {
 
 .animation-timeline .animation .fill.delay::after,
 .animation-timeline .animation .fill.end-delay::after {
   border-color: var(--fill-enable-color);
   background-color: var(--fill-enable-color);
 }
 
 /* Animation target node gutter, contains a preview of the dom node */
-
 .animation-target {
   background-color: var(--theme-toolbar-background);
   padding: 0 4px;
   box-sizing: border-box;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
   cursor: pointer;
@@ -515,77 +525,69 @@ body {
 
 .animation-target .node-highlighter:active,
 .animation-target .node-highlighter.selected {
   filter: url(images/filters.svg#checked-icon-state) brightness(0.9);
 }
 
 /* Inline keyframes info in the timeline */
 
-.animation-timeline .animated-properties:not(.selected) {
-  display: none;
-}
-
-.animation-timeline .animated-properties {
-  background-color: var(--theme-selection-background-semitransparent);
-}
-
-.animation-timeline .animated-properties .property {
+.animation-detail .animated-properties .property {
   height: var(--timeline-animation-height);
   position: relative;
 }
 
-.animation-timeline .animated-properties .property:nth-child(2n) {
+.animation-detail .animated-properties .property:nth-child(2n) {
   background-color: var(--even-animation-timeline-background-color);
 }
 
-.animation-timeline .animated-properties .name {
+.animation-detail .animated-properties .name {
   width: var(--timeline-sidebar-width);
   padding-right: var(--keyframes-marker-size);
   box-sizing: border-box;
   height: 100%;
   color: var(--theme-body-color-alt);
   white-space: nowrap;
   display: flex;
   justify-content: flex-end;
   align-items: center;
 }
 
-.animation-timeline .animated-properties .name div {
+.animation-detail .animated-properties .name div {
   overflow: hidden;
   text-overflow: ellipsis;
 }
 
-.animated-properties.cssanimation {
+.animation-detail .animated-properties.cssanimation {
   --background-color: var(--theme-contrast-background);
 }
 
-.animated-properties.csstransition {
+.animation-detail .animated-properties.csstransition {
   --background-color: var(--theme-highlight-blue);
 }
 
-.animated-properties.scriptanimation {
+.animation-detail .animated-properties.scriptanimation {
   --background-color: var(--theme-graphs-green);
 }
 
-.animation-timeline .animated-properties .oncompositor::before {
+.animation-detail .animated-properties .oncompositor::before {
   content: "";
   display: inline-block;
   width: 17px;
   height: 17px;
   background-color: var(--background-color);
   clip-path: url(images/animation-fast-track.svg#thunderbolt);
   vertical-align: middle;
 }
 
-.animation-timeline .animated-properties .warning {
+.animation-detail .animated-properties .warning {
   text-decoration: underline dotted;
 }
 
-.animation-timeline .animated-properties .frames {
+.animation-detail .animated-properties .frames {
   /* The frames list is absolutely positioned and the left and width properties
      are dynamically set from javascript to match the animation's startTime and
      duration */
   position: absolute;
   top: 0;
   left: 0;
   height: 100%;
   width: 100%;
@@ -601,17 +603,16 @@ body {
   /* Actual keyframe markers are positioned absolutely within this container and
      their position is relative to its size (we know the offset of each frame
      in percentage) */
   position: absolute;
   left: 0;
   top: 0;
   width: 100%;
   height: 100%;
-
 }
 
 .keyframes .frame {
   position: absolute;
   top: 50%;
   width: 0;
   height: 0;
   background-color: inherit;
@@ -665,32 +666,88 @@ body {
 
 .keyframes svg path.transform {
   fill: var(--transform-background-color);
   stroke: var(--transform-border-color);
 }
 
 .keyframes svg path.color {
   stroke: none;
+  height: 100%;
+}
+
+.animation-detail {
+  position: relative;
+  width: 100%;
+  background-color: var(--theme-body-background);
+  z-index: 5;
+}
+
+.animation-detail .animation-detail-header {
+  height: var(--toolbar-height);
+  width: 100%;
+}
+
+.animation-detail .animation-detail-header > div {
+  position: fixed;
+  display: flex;
+  flex-wrap: nowrap;
+  width: 100%;
+  height: var(--toolbar-height);
+  line-height: var(--toolbar-height);
+  background-color: var(--theme-body-background);
+  z-index: 5;
+}
+
+.animation-detail .animation-detail-header > div > div {
+  white-space: nowrap;
+}
+
+.animation-detail .animation-detail-header > div > div:first-child {
+  margin-left: 15px;
+}
+
+.animation-detail .animation-detail-header > div > div:nth-child(2) {
+  margin-left: .5em;
+}
+
+.animation-detail .animation-detail-body {
+  position: relative;
+  background-color: var(--theme-body-background);
+}
+
+.animation-detail .animation-detail-body .animated-properties {
+  position: relative;
+  height: 100%;
 }
 
 .animated-properties-header {
+  -moz-user-select: none;
+  position: sticky;
+  top: var(--timeline-animation-height);
   min-height: var(--timeline-animation-height);
-  -moz-user-select: none;
+  padding-top: 2px;
+  z-index: 3;
+  background-color: var(--theme-body-background);
 }
 
 .animated-properties-header .header-item:nth-child(2) {
   left: 50%;
 }
 
 .animated-properties-header .header-item:nth-child(3) {
-  right: 0;
+  right: -0.5px;
   border-left: none;
   border-right: 0.5px solid var(--time-graduation-border-color);
 }
 
 .progress-tick-container .progress-tick:nth-child(2) {
   left: 50%;
 }
 
 .progress-tick-container .progress-tick:nth-child(3) {
   left: 100%;
 }
+
+.animated-properties-body .property:last-child {
+  /* To display animation progress graph clealy when the scroll is bottom. */
+  padding-bottom: calc(var(--timeline-animation-height) / 2);
+}