Bug 1416106 - Part 6: Implement distance graph. r?gl draft
authorDaisuke Akatsuka <dakatsuka@mozilla.com>
Wed, 14 Feb 2018 16:27:36 +0900
changeset 754852 891782cc9776fb7ff6bbb554edf9118051ec029f
parent 754851 b5350e4eefe4699452fb690d4bcaa09bed37fcc1
child 754853 3a3bf6a95a2a832e7efeb97d8bc4408636420c83
push id99015
push userbmo:dakatsuka@mozilla.com
push dateWed, 14 Feb 2018 11:58:26 +0000
reviewersgl
bugs1416106
milestone60.0a1
Bug 1416106 - Part 6: Implement distance graph. r?gl MozReview-Commit-ID: Hlws41Ex7jj
devtools/client/inspector/animation/components/AnimatedPropertyItem.js
devtools/client/inspector/animation/components/AnimatedPropertyList.js
devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js
devtools/client/inspector/animation/components/AnimationDetailContainer.js
devtools/client/inspector/animation/components/App.js
devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js
devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js
devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js
devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js
devtools/client/inspector/animation/components/keyframes-graph/moz.build
devtools/client/inspector/animation/utils/graph-helper.js
devtools/client/themes/animation.css
--- a/devtools/client/inspector/animation/components/AnimatedPropertyItem.js
+++ b/devtools/client/inspector/animation/components/AnimatedPropertyItem.js
@@ -10,35 +10,43 @@ const PropTypes = require("devtools/clie
 
 const AnimatedPropertyName = createFactory(require("./AnimatedPropertyName"));
 const KeyframesGraph = createFactory(require("./keyframes-graph/KeyframesGraph"));
 
 class AnimatedPropertyItem extends PureComponent {
   static get propTypes() {
     return {
       property: PropTypes.string.isRequired,
+      simulateAnimation: PropTypes.func.isRequired,
       state: PropTypes.object.isRequired,
       values: PropTypes.array.isRequired,
     };
   }
 
   render() {
     const {
       property,
+      simulateAnimation,
       state,
+      values,
     } = this.props;
 
     return dom.li(
       {
         className: "animated-property-item"
       },
       AnimatedPropertyName(
         {
           property,
           state,
         }
       ),
-      KeyframesGraph()
+      KeyframesGraph(
+        {
+          simulateAnimation,
+          values,
+        }
+      )
     );
   }
 }
 
 module.exports = AnimatedPropertyItem;
--- a/devtools/client/inspector/animation/components/AnimatedPropertyList.js
+++ b/devtools/client/inspector/animation/components/AnimatedPropertyList.js
@@ -11,16 +11,17 @@ const PropTypes = require("devtools/clie
 const AnimatedPropertyItem = createFactory(require("./AnimatedPropertyItem"));
 
 class AnimatedPropertyList extends PureComponent {
   static get propTypes() {
     return {
       animation: PropTypes.object.isRequired,
       emitEventForTest: PropTypes.func.isRequired,
       getAnimatedPropertyMap: PropTypes.func.isRequired,
+      simulateAnimation: PropTypes.func.isRequired,
     };
   }
 
   constructor(props) {
     super(props);
 
     this.state = {
       animatedPropertyMap: null
@@ -55,31 +56,37 @@ class AnimatedPropertyList extends PureC
     const animatedPropertyMap = await getAnimatedPropertyMap(animation);
 
     this.setState({ animatedPropertyMap });
 
     emitEventForTest("animation-keyframes-rendered");
   }
 
   render() {
-    const { animatedPropertyMap } = this.state;
+    const {
+      simulateAnimation,
+    } = this.props;
+    const {
+      animatedPropertyMap,
+    } = this.state;
 
     if (!animatedPropertyMap) {
       return null;
     }
 
     return dom.ul(
       {
         className: "animated-property-list"
       },
       [...animatedPropertyMap.entries()].map(([property, values]) => {
         const state = this.getPropertyState(property);
         return AnimatedPropertyItem(
           {
             property,
+            simulateAnimation,
             state,
             values,
           }
         );
       })
     );
   }
 }
--- a/devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js
+++ b/devtools/client/inspector/animation/components/AnimatedPropertyListContainer.js
@@ -12,35 +12,38 @@ const AnimatedPropertyList = createFacto
 const AnimatedPropertyListHeader = createFactory(require("./AnimatedPropertyListHeader"));
 
 class AnimatedPropertyListContainer extends PureComponent {
   static get propTypes() {
     return {
       animation: PropTypes.object.isRequired,
       emitEventForTest: PropTypes.func.isRequired,
       getAnimatedPropertyMap: PropTypes.func.isRequired,
+      simulateAnimation: PropTypes.func.isRequired,
     };
   }
 
   render() {
     const {
       animation,
       emitEventForTest,
       getAnimatedPropertyMap,
+      simulateAnimation,
     } = this.props;
 
     return dom.div(
       {
         className: `animated-property-list-container ${ animation.state.type }`
       },
       AnimatedPropertyListHeader(),
       AnimatedPropertyList(
         {
           animation,
           emitEventForTest,
           getAnimatedPropertyMap,
+          simulateAnimation,
         }
       )
     );
   }
 }
 
 module.exports = AnimatedPropertyListContainer;
--- a/devtools/client/inspector/animation/components/AnimationDetailContainer.js
+++ b/devtools/client/inspector/animation/components/AnimationDetailContainer.js
@@ -15,25 +15,27 @@ const AnimatedPropertyListContainer =
 
 class AnimationDetailContainer extends PureComponent {
   static get propTypes() {
     return {
       animation: PropTypes.object.isRequired,
       emitEventForTest: PropTypes.func.isRequired,
       getAnimatedPropertyMap: PropTypes.func.isRequired,
       setDetailVisibility: PropTypes.func.isRequired,
+      simulateAnimation: PropTypes.func.isRequired,
     };
   }
 
   render() {
     const {
       animation,
       emitEventForTest,
       getAnimatedPropertyMap,
       setDetailVisibility,
+      simulateAnimation,
     } = this.props;
 
     return dom.div(
       {
         className: "animation-detail-container"
       },
       animation ?
         AnimationDetailHeader(
@@ -45,16 +47,17 @@ class AnimationDetailContainer extends P
       :
         null,
       animation ?
         AnimatedPropertyListContainer(
           {
             animation,
             emitEventForTest,
             getAnimatedPropertyMap,
+            simulateAnimation,
           }
         )
       :
         null
     );
   }
 }
 
--- a/devtools/client/inspector/animation/components/App.js
+++ b/devtools/client/inspector/animation/components/App.js
@@ -60,16 +60,17 @@ class App extends PureComponent {
       animations.length ?
       SplitBox({
         className: "animation-container-splitter",
         endPanel: AnimationDetailContainer(
           {
             emitEventForTest,
             getAnimatedPropertyMap,
             setDetailVisibility,
+            simulateAnimation,
           }
         ),
         endPanelControl: true,
         initialHeight: "50%",
         splitterSize: 1,
         startPanel: AnimationListContainer(
           {
             animations,
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/ComputedStylePath.js
@@ -0,0 +1,153 @@
+/* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+const {
+  createPathSegments,
+  DEFAULT_DURATION_RESOLUTION,
+  getPreferredProgressThresholdByKeyframes,
+  toPathString,
+} = require("../../utils/graph-helper");
+
+/*
+ * This class is an abstraction for computed style path of keyframes.
+ * Subclass of this should implement the following methods:
+ *
+ * getPropertyName()
+ *   Returns property name which will be animated.
+ *   @return {String}
+ *           e.g. opacity
+ *
+ * getPropertyValue(keyframe)
+ *   Returns value which uses as animated keyframe value from given parameter.
+ *   @param {Object} keyframe
+ *   @return {String||Number}
+ *           e.g. 0
+ *
+ * toSegmentValue(computedStyle)
+ *   Convert computed style to segment value of graph.
+ *   @param {String||Number}
+ *          e.g. 0
+ *   @return {Number}
+ *          e.g. 0 (should be 0 - 1.0)
+ */
+class ComputedStylePath extends PureComponent {
+  static get propTypes() {
+    return {
+      componentWidth: PropTypes.number.isRequired,
+      graphHeight: PropTypes.number.isRequired,
+      simulateAnimation: PropTypes.func.isRequired,
+      totalDuration: PropTypes.number.isRequired,
+      values: PropTypes.array.isRequired,
+    };
+  }
+
+  /**
+   * Return an array containing the path segments between the given start and
+   * end keyframe values.
+   *
+   * @param {Object} startValue
+   *        Starting keyframe.
+   * @param {Object} startValue
+   *        Ending keyframe.
+   * @return {Array}
+   *         Array of path segment.
+   *         [{x: {Number} time, y: {Number} segment value}, ...]
+   */
+  getPathSegments(startValue, endValue) {
+    const {
+      componentWidth,
+      simulateAnimation,
+      totalDuration,
+    } = this.props;
+
+    const propertyName = this.getPropertyName();
+    const offsetDistance = endValue.offset - startValue.offset;
+    const duration = offsetDistance * totalDuration;
+
+    const keyframes = [startValue, endValue].map((keyframe, index) => {
+      return {
+        offset: index,
+        easing: keyframe.easing,
+        [propertyName]: this.getPropertyValue(keyframe),
+      };
+    });
+    const effect = {
+      duration,
+      fill: "forwards",
+    };
+    const simulatedAnimation = simulateAnimation(keyframes, effect, true);
+    const simulatedElement = simulatedAnimation.effect.target;
+    const win = simulatedElement.ownerGlobal;
+    const threshold = getPreferredProgressThresholdByKeyframes(keyframes);
+
+    const getSegment = time => {
+      simulatedAnimation.currentTime = time;
+      const computedStyle =
+        win.getComputedStyle(simulatedElement).getPropertyValue(propertyName);
+
+      return {
+        computedStyle,
+        x: time,
+        y: this.toSegmentValue(computedStyle),
+      };
+    };
+
+    const segments = createPathSegments(0, duration, duration / componentWidth, threshold,
+                                        DEFAULT_DURATION_RESOLUTION, getSegment);
+    const offset = startValue.offset * totalDuration;
+
+    for (const segment of segments) {
+      segment.x += offset;
+    }
+
+    return segments;
+  }
+
+  /**
+   * Render graph. This method returns React dom.
+   *
+   * @return {Element}
+   */
+  renderGraph() {
+    const { values } = this.props;
+
+    const segments = [];
+
+    for (let i = 0; i < values.length - 1; i++) {
+      const startValue = values[i];
+      const endValue = values[i + 1];
+      segments.push(...this.getPathSegments(startValue, endValue));
+    }
+
+    return this.renderPathSegments(segments);
+  }
+
+  /**
+   * Return react dom fron given path segments.
+   *
+   * @param {Array} segments
+   * @return {Element}
+   */
+  renderPathSegments(segments) {
+    const { graphHeight } = this.props;
+
+    for (const segment of segments) {
+      segment.y *= graphHeight;
+    }
+
+    let d = `M${ segments[0].x },0 `;
+    d += toPathString(segments);
+    d += `L${ segments[segments.length - 1].x },0 Z`;
+
+    return dom.path({ d });
+  }
+}
+
+module.exports = ComputedStylePath;
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/DistancePath.js
@@ -0,0 +1,34 @@
+/* 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 dom = require("devtools/client/shared/vendor/react-dom-factories");
+
+const ComputedStylePath = require("./ComputedStylePath");
+
+class DistancePath extends ComputedStylePath {
+  getPropertyName() {
+    return "opacity";
+  }
+
+  getPropertyValue(keyframe) {
+    return keyframe.distance;
+  }
+
+  toSegmentValue(computedStyle) {
+    return computedStyle;
+  }
+
+  render() {
+    return dom.g(
+      {
+        className: "distance-path",
+      },
+      super.renderGraph()
+    );
+  }
+}
+
+module.exports = DistancePath;
--- a/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js
+++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraph.js
@@ -1,20 +1,41 @@
 /* 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 { PureComponent } = require("devtools/client/shared/vendor/react");
+const { createFactory, PureComponent } = require("devtools/client/shared/vendor/react");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+
+const KeyframesGraphPath = createFactory(require("./KeyframesGraphPath"));
 
 class KeyframesGraph extends PureComponent {
+  static get propTypes() {
+    return {
+      simulateAnimation: PropTypes.func.isRequired,
+      values: PropTypes.array.isRequired,
+    };
+  }
+
   render() {
+    const {
+      simulateAnimation,
+      values,
+    } = this.props;
+
     return dom.div(
       {
-        className: "keyframes-graph"
-      }
+        className: "keyframes-graph",
+      },
+      KeyframesGraphPath(
+        {
+          simulateAnimation,
+          values,
+        }
+      )
     );
   }
 }
 
 module.exports = KeyframesGraph;
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/components/keyframes-graph/KeyframesGraphPath.js
@@ -0,0 +1,75 @@
+/* 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 { createFactory, PureComponent } = require("devtools/client/shared/vendor/react");
+const dom = require("devtools/client/shared/vendor/react-dom-factories");
+const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom");
+
+const DistancePath = createFactory(require("./DistancePath"));
+
+const {
+  DEFAULT_GRAPH_HEIGHT,
+  DEFAULT_KEYFRAMES_GRAPH_DURATION,
+} = require("../../utils/graph-helper");
+
+class KeyframesGraphPath extends PureComponent {
+  static get propTypes() {
+    return {
+      simulateAnimation: PropTypes.func.isRequired,
+      values: PropTypes.array.isRequired,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      componentWidth: 0,
+    };
+  }
+
+  componentDidMount() {
+    this.updateState();
+  }
+
+  updateState() {
+    const thisEl = ReactDOM.findDOMNode(this);
+    this.setState({ componentWidth: thisEl.parentNode.clientWidth });
+  }
+
+  render() {
+    const {
+      simulateAnimation,
+      values,
+    } = this.props;
+    const { componentWidth } = this.state;
+
+    if (!componentWidth) {
+      return dom.svg();
+    }
+
+    return dom.svg(
+      {
+        className: "keyframes-graph-path",
+        preserveAspectRatio: "none",
+        viewBox: `0 -${ DEFAULT_GRAPH_HEIGHT } `
+                 + `${ DEFAULT_KEYFRAMES_GRAPH_DURATION } ${ DEFAULT_GRAPH_HEIGHT }`,
+      },
+      DistancePath(
+        {
+          componentWidth,
+          graphHeight: DEFAULT_GRAPH_HEIGHT,
+          simulateAnimation,
+          totalDuration: DEFAULT_KEYFRAMES_GRAPH_DURATION,
+          values,
+        }
+      )
+    );
+  }
+}
+
+module.exports = KeyframesGraphPath;
--- a/devtools/client/inspector/animation/components/keyframes-graph/moz.build
+++ b/devtools/client/inspector/animation/components/keyframes-graph/moz.build
@@ -1,7 +1,10 @@
 # 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(
+    'ComputedStylePath.js',
+    'DistancePath.js',
     'KeyframesGraph.js',
+    'KeyframesGraphPath.js',
 )
--- a/devtools/client/inspector/animation/utils/graph-helper.js
+++ b/devtools/client/inspector/animation/utils/graph-helper.js
@@ -1,31 +1,33 @@
 /* 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";
 
 // BOUND_EXCLUDING_TIME should be less than 1ms and is used to exclude start
-// and end bounds when dividing  duration in createPathSegments.
+// and end bounds when dividing duration in createPathSegments.
 const BOUND_EXCLUDING_TIME = 0.001;
 // We define default graph height since if the height of viewport in SVG is
 // too small (e.g. 1), vector-effect may not be able to calculate correctly.
 const DEFAULT_GRAPH_HEIGHT = 100;
+// Default animation duration for keyframes graph.
+const DEFAULT_KEYFRAMES_GRAPH_DURATION = 1000;
 // DEFAULT_MIN_PROGRESS_THRESHOLD shoud be between more than 0 to 1.
 const DEFAULT_MIN_PROGRESS_THRESHOLD = 0.1;
 // In the createPathSegments function, an animation duration is divided by
-// DURATION_RESOLUTION in order to draw the way the animation progresses.
+// DEFAULT_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
 // DEFAULT_MIN_PROGRESS_THRESHOLD * DEFAULT_GRAPH_HEIGHT, then createPathSegments
-// re-divides by DURATION_RESOLUTION.
-// DURATION_RESOLUTION shoud be integer and more than 2.
-const DURATION_RESOLUTION = 4;
+// re-divides by DEFAULT_DURATION_RESOLUTION.
+// DEFAULT_DURATION_RESOLUTION shoud be integer and more than 2.
+const DEFAULT_DURATION_RESOLUTION = 4;
 
 /**
  * The helper class for creating summary graph.
  */
 class SummaryGraphHelper {
   /**
    * Constructor.
    *
@@ -117,34 +119,34 @@ function createPathSegments(startTime, e
   let pathSegments = [];
 
   // Append the segment for the startTime position.
   const startTimeSegment = 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.
+  // See the definition of DEFAULT_DURATION_RESOLUTION for more information about this.
   const interval = (endTime - startTime) / resolution;
   for (let index = 1; index <= resolution; index++) {
     // Create a segment for this interval.
     const currentSegment = 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 BOUND_EXCLUDING_TIME).
       const nextStartTime = previousSegment.x + BOUND_EXCLUDING_TIME;
       const nextEndTime = currentSegment.x - BOUND_EXCLUDING_TIME;
       const segments =
         createPathSegments(nextStartTime, nextEndTime, minSegmentDuration,
-                           minProgressThreshold, DURATION_RESOLUTION, getSegment);
+                           minProgressThreshold, DEFAULT_DURATION_RESOLUTION, getSegment);
       pathSegments = pathSegments.concat(segments);
     }
 
     pathSegments.push(currentSegment);
     previousSegment = currentSegment;
   }
 
   return pathSegments;
@@ -156,20 +158,20 @@ function createPathSegments(startTime, e
  *
  * @param {Array} keyframes
  *        Array of keyframe.
  * @return {Number}
  *         Preferred duration resolution.
  */
 function getPreferredDurationResolution(keyframes) {
   if (!keyframes) {
-    return DURATION_RESOLUTION;
+    return DEFAULT_DURATION_RESOLUTION;
   }
 
-  let durationResolution = DURATION_RESOLUTION;
+  let durationResolution = DEFAULT_DURATION_RESOLUTION;
   let previousOffset = 0;
   for (let keyframe of keyframes) {
     if (previousOffset && previousOffset != keyframe.offset) {
       const interval = keyframe.offset - previousOffset;
       durationResolution = Math.max(durationResolution, Math.ceil(1 / interval));
     }
     previousOffset = keyframe.offset;
   }
@@ -194,16 +196,33 @@ function getPreferredProgressThreshold(s
   if ((stepsOrFrames = getStepsOrFramesCount(state.easing))) {
     threshold = Math.min(threshold, (1 / (stepsOrFrames + 1)));
   }
 
   if (!keyframes) {
     return threshold;
   }
 
+  threshold = Math.min(threshold, getPreferredProgressThresholdByKeyframes(keyframes));
+
+  return threshold;
+}
+
+/**
+ * Return preferred progress threshold by keyframes.
+ *
+ * @param {Array} keyframes
+ *        Array of keyframe.
+ * @return {float}
+ *         Preferred threshold.
+ */
+function getPreferredProgressThresholdByKeyframes(keyframes) {
+  let threshold = DEFAULT_MIN_PROGRESS_THRESHOLD;
+  let stepsOrFrames;
+
   for (let i = 0; i < keyframes.length - 1; i++) {
     const keyframe = keyframes[i];
 
     if (!keyframe.easing) {
       continue;
     }
 
     if ((stepsOrFrames = getStepsOrFramesCount(keyframe.easing))) {
@@ -234,11 +253,16 @@ function getStepsOrFramesCount(easing) {
 function toPathString(segments) {
   let pathString = "";
   segments.forEach(segment => {
     pathString += `L${ segment.x },${ segment.y } `;
   });
   return pathString;
 }
 
-module.exports.DEFAULT_GRAPH_HEIGHT = DEFAULT_GRAPH_HEIGHT;
+exports.createPathSegments = createPathSegments;
+exports.DEFAULT_DURATION_RESOLUTION = DEFAULT_DURATION_RESOLUTION;
+exports.DEFAULT_GRAPH_HEIGHT = DEFAULT_GRAPH_HEIGHT;
+exports.DEFAULT_KEYFRAMES_GRAPH_DURATION = DEFAULT_KEYFRAMES_GRAPH_DURATION;
+exports.getPreferredProgressThresholdByKeyframes =
+  getPreferredProgressThresholdByKeyframes;
 exports.SummaryGraphHelper = SummaryGraphHelper;
 exports.toPathString = toPathString;
--- a/devtools/client/themes/animation.css
+++ b/devtools/client/themes/animation.css
@@ -355,19 +355,31 @@
 
 .animated-property-name.warning span {
   text-decoration: underline dotted;
 }
 
 /* Keyframes Graph */
 .keyframes-graph {
   height: 100%;
+  padding-top: 5px;
   width: calc(100% - var(--sidebar-width) - var(--graph-right-offset));
 }
 
+.keyframes-graph-path {
+  height: 100%;
+  width: 100%;
+}
+
+.keyframes-graph-path path {
+  fill: lime;
+  vector-effect: non-scaling-stroke;
+  transform: scale(1, -1);
+}
+
 /* No Animation Panel */
 .animation-error-message {
   overflow: auto;
 }
 
 .animation-error-message > p {
   white-space: pre;
 }