Bug 1431573 - Part 7: Implement scrubber. r?gl draft
authorDaisuke Akatsuka <dakatsuka@mozilla.com>
Mon, 12 Mar 2018 12:56:09 +0900
changeset 766118 172a1d9adca77696ce2f50173c500b6482893e56
parent 764144 fdba46c58ac34430c1c987216a5140b15a13cca5
child 766119 e6a8508fd23fe1668a86a3a08d87f6b4a6241ddc
push id102230
push userbmo:dakatsuka@mozilla.com
push dateMon, 12 Mar 2018 09:02:24 +0000
reviewersgl
bugs1431573
milestone60.0a1
Bug 1431573 - Part 7: Implement scrubber. r?gl MozReview-Commit-ID: B6CeCDWawym
devtools/client/inspector/animation/animation.js
devtools/client/inspector/animation/components/AnimationListContainer.js
devtools/client/inspector/animation/components/AnimationListHeader.js
devtools/client/inspector/animation/components/App.js
devtools/client/inspector/animation/components/CurrentTimeScrubber.js
devtools/client/inspector/animation/components/CurrentTimeScrubberController.js
devtools/client/inspector/animation/components/moz.build
devtools/client/themes/animation.css
--- a/devtools/client/inspector/animation/animation.js
+++ b/devtools/client/inspector/animation/animation.js
@@ -33,16 +33,17 @@ class AnimationInspector {
       this.addAnimationsCurrentTimeListener.bind(this);
     this.getAnimatedPropertyMap = this.getAnimatedPropertyMap.bind(this);
     this.getComputedStyle = this.getComputedStyle.bind(this);
     this.getNodeFromActor = this.getNodeFromActor.bind(this);
     this.removeAnimationsCurrentTimeListener =
       this.removeAnimationsCurrentTimeListener.bind(this);
     this.rewindAnimationsCurrentTime = this.rewindAnimationsCurrentTime.bind(this);
     this.selectAnimation = this.selectAnimation.bind(this);
+    this.setAnimationsCurrentTime = this.setAnimationsCurrentTime.bind(this);
     this.setAnimationsPlaybackRate = this.setAnimationsPlaybackRate.bind(this);
     this.setAnimationsPlayState = this.setAnimationsPlayState.bind(this);
     this.setDetailVisibility = this.setDetailVisibility.bind(this);
     this.simulateAnimation = this.simulateAnimation.bind(this);
     this.toggleElementPicker = this.toggleElementPicker.bind(this);
     this.update = this.update.bind(this);
     this.onAnimationsCurrentTimeUpdated = this.onAnimationsCurrentTimeUpdated.bind(this);
     this.onElementPickerStarted = this.onElementPickerStarted.bind(this);
@@ -71,27 +72,29 @@ class AnimationInspector {
       emit: emitEventForTest,
       getAnimatedPropertyMap,
       getComputedStyle,
       getNodeFromActor,
       isAnimationsRunning,
       removeAnimationsCurrentTimeListener,
       rewindAnimationsCurrentTime,
       selectAnimation,
+      setAnimationsCurrentTime,
       setAnimationsPlaybackRate,
       setAnimationsPlayState,
       setDetailVisibility,
       simulateAnimation,
       toggleElementPicker,
     } = this;
 
     const target = this.inspector.target;
     this.animationsFront = new AnimationsFront(target.client, target.form);
 
     this.animationsCurrentTimeListeners = [];
+    this.isCurrentTimeSet = false;
 
     const provider = createElement(Provider,
       {
         id: "newanimationinspector",
         key: "newanimationinspector",
         store: this.inspector.store
       },
       App(
@@ -102,16 +105,17 @@ class AnimationInspector {
           getComputedStyle,
           getNodeFromActor,
           isAnimationsRunning,
           onHideBoxModelHighlighter,
           onShowBoxModelHighlighterForNode,
           removeAnimationsCurrentTimeListener,
           rewindAnimationsCurrentTime,
           selectAnimation,
+          setAnimationsCurrentTime,
           setAnimationsPlaybackRate,
           setAnimationsPlayState,
           setDetailVisibility,
           setSelectedNode,
           simulateAnimation,
           toggleElementPicker,
         }
       )
@@ -221,16 +225,23 @@ class AnimationInspector {
   }
 
   isPanelVisible() {
     return this.inspector && this.inspector.toolbox && this.inspector.sidebar &&
            this.inspector.toolbox.currentToolId === "inspector" &&
            this.inspector.sidebar.getCurrentTabID() === "newanimationinspector";
   }
 
+  /**
+   * This method should call when the current time is changed.
+   * Then, dispatches the current time to listeners that are registered
+   * by addAnimationsCurrentTimeListener.
+   *
+   * @param {Number} currentTime
+   */
   onAnimationsCurrentTimeUpdated(currentTime) {
     for (const listener of this.animationsCurrentTimeListeners) {
       listener(currentTime);
     }
   }
 
   onElementPickerStarted() {
     this.inspector.store.dispatch(updateElementPickerEnabled(true));
@@ -254,40 +265,58 @@ class AnimationInspector {
   }
 
   removeAnimationsCurrentTimeListener(listener) {
     this.animationsCurrentTimeListeners =
       this.animationsCurrentTimeListeners.filter(l => l !== listener);
   }
 
   async rewindAnimationsCurrentTime() {
-    const animations = this.state.animations;
-    await this.animationsFront.setCurrentTimes(animations, 0, true);
-    await this.updateAnimations(animations);
-    this.onAnimationsCurrentTimeUpdated(0);
+    await this.setAnimationsCurrentTime(0, true);
   }
 
   selectAnimation(animation) {
     this.inspector.store.dispatch(updateSelectedAnimation(animation));
   }
 
+  async setAnimationsCurrentTime(currentTime, shouldRefresh) {
+    this.stopAnimationsCurrentTimeTimer();
+    this.onAnimationsCurrentTimeUpdated(currentTime);
+
+    if (!shouldRefresh && this.isCurrentTimeSet) {
+      return;
+    }
+
+    const animations = this.state.animations;
+    this.isCurrentTimeSet = true;
+    await this.animationsFront.setCurrentTimes(animations, currentTime, true);
+    await this.updateAnimations(animations);
+    this.isCurrentTimeSet = false;
+
+    if (shouldRefresh) {
+      this.updateState([...animations]);
+    }
+  }
+
   async setAnimationsPlaybackRate(playbackRate) {
     const animations = this.state.animations;
     await this.animationsFront.setPlaybackRates(animations, playbackRate);
     await this.updateAnimations(animations);
+    await this.updateState([...animations]);
   }
 
   async setAnimationsPlayState(doPlay) {
     if (doPlay) {
       await this.animationsFront.playAll();
     } else {
       await this.animationsFront.pauseAll();
     }
 
-    this.updateAnimations(this.state.animations);
+    await this.updateAnimations(this.state.animations);
+    await this.updateState([...this.state.animations]);
   }
 
   setDetailVisibility(isVisible) {
     this.inspector.store.dispatch(updateDetailVisibility(isVisible));
   }
 
   /**
    * Returns simulatable animation by given parameters.
@@ -371,18 +400,16 @@ class AnimationInspector {
   }
 
   async updateAnimations(animations) {
     const promises = animations.map(animation => {
       return animation.refreshState();
     });
 
     await Promise.all(promises);
-
-    this.updateState([...animations]);
   }
 
   updateState(animations) {
     this.stopAnimationsCurrentTimeTimer();
 
     // If number of displayed animations is one, we select the animation automatically.
     // But if selected animation is in given animations, ignores.
     const selectedAnimation = this.state.selectedAnimation;
--- a/devtools/client/inspector/animation/components/AnimationListContainer.js
+++ b/devtools/client/inspector/animation/components/AnimationListContainer.js
@@ -10,49 +10,58 @@ const PropTypes = require("devtools/clie
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
 
 const AnimationList = createFactory(require("./AnimationList"));
 const AnimationListHeader = createFactory(require("./AnimationListHeader"));
 
 class AnimationListContainer extends PureComponent {
   static get propTypes() {
     return {
+      addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
       animations: PropTypes.arrayOf(PropTypes.object).isRequired,
       emitEventForTest: PropTypes.func.isRequired,
       getAnimatedPropertyMap: PropTypes.func.isRequired,
       getNodeFromActor: PropTypes.func.isRequired,
       onHideBoxModelHighlighter: PropTypes.func.isRequired,
       onShowBoxModelHighlighterForNode: PropTypes.func.isRequired,
+      removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
       selectAnimation: PropTypes.func.isRequired,
+      setAnimationsCurrentTime: PropTypes.func.isRequired,
       setSelectedNode: PropTypes.func.isRequired,
       simulateAnimation: PropTypes.func.isRequired,
       timeScale: PropTypes.object.isRequired,
     };
   }
 
   render() {
     const {
+      addAnimationsCurrentTimeListener,
       animations,
       emitEventForTest,
       getAnimatedPropertyMap,
       getNodeFromActor,
       onHideBoxModelHighlighter,
       onShowBoxModelHighlighterForNode,
+      removeAnimationsCurrentTimeListener,
       selectAnimation,
+      setAnimationsCurrentTime,
       setSelectedNode,
       simulateAnimation,
       timeScale,
     } = this.props;
 
     return dom.div(
       {
         className: "animation-list-container"
       },
       AnimationListHeader(
         {
+          addAnimationsCurrentTimeListener,
+          removeAnimationsCurrentTimeListener,
+          setAnimationsCurrentTime,
           timeScale,
         }
       ),
       AnimationList(
         {
           animations,
           emitEventForTest,
           getAnimatedPropertyMap,
--- a/devtools/client/inspector/animation/components/AnimationListHeader.js
+++ b/devtools/client/inspector/animation/components/AnimationListHeader.js
@@ -5,33 +5,51 @@
 "use strict";
 
 const { createFactory, PureComponent } =
   require("devtools/client/shared/vendor/react");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
 
 const AnimationTimelineTickList = createFactory(require("./AnimationTimelineTickList"));
+const CurrentTimeScrubberController =
+  createFactory(require("./CurrentTimeScrubberController"));
 
 class AnimationListHeader extends PureComponent {
   static get propTypes() {
     return {
+      addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+      removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+      setAnimationsCurrentTime: PropTypes.func.isRequired,
       timeScale: PropTypes.object.isRequired,
     };
   }
 
   render() {
-    const { timeScale } = this.props;
+    const {
+      addAnimationsCurrentTimeListener,
+      removeAnimationsCurrentTimeListener,
+      setAnimationsCurrentTime,
+      timeScale,
+    } = this.props;
 
     return dom.div(
       {
         className: "animation-list-header devtools-toolbar"
       },
       AnimationTimelineTickList(
         {
           timeScale
         }
+      ),
+      CurrentTimeScrubberController(
+        {
+          addAnimationsCurrentTimeListener,
+          removeAnimationsCurrentTimeListener,
+          setAnimationsCurrentTime,
+          timeScale,
+        }
       )
     );
   }
 }
 
 module.exports = AnimationListHeader;
--- a/devtools/client/inspector/animation/components/App.js
+++ b/devtools/client/inspector/animation/components/App.js
@@ -25,16 +25,17 @@ class App extends PureComponent {
       getAnimatedPropertyMap: PropTypes.func.isRequired,
       getComputedStyle: PropTypes.func.isRequired,
       getNodeFromActor: PropTypes.func.isRequired,
       onHideBoxModelHighlighter: PropTypes.func.isRequired,
       onShowBoxModelHighlighterForNode: PropTypes.func.isRequired,
       removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
       rewindAnimationsCurrentTime: PropTypes.func.isRequired,
       selectAnimation: PropTypes.func.isRequired,
+      setAnimationsCurrentTime: PropTypes.func.isRequired,
       setAnimationsPlaybackRate: PropTypes.func.isRequired,
       setAnimationsPlayState: PropTypes.func.isRequired,
       setDetailVisibility: PropTypes.func.isRequired,
       setSelectedNode: PropTypes.func.isRequired,
       simulateAnimation: PropTypes.func.isRequired,
       timeScale: PropTypes.object.isRequired,
       toggleElementPicker: PropTypes.func.isRequired,
     };
@@ -53,16 +54,17 @@ class App extends PureComponent {
       getAnimatedPropertyMap,
       getComputedStyle,
       getNodeFromActor,
       onHideBoxModelHighlighter,
       onShowBoxModelHighlighterForNode,
       removeAnimationsCurrentTimeListener,
       rewindAnimationsCurrentTime,
       selectAnimation,
+      setAnimationsCurrentTime,
       setAnimationsPlaybackRate,
       setAnimationsPlayState,
       setDetailVisibility,
       setSelectedNode,
       simulateAnimation,
       timeScale,
       toggleElementPicker,
     } = this.props;
@@ -95,23 +97,26 @@ class App extends PureComponent {
               simulateAnimation,
             }
           ),
           endPanelControl: true,
           initialHeight: "50%",
           splitterSize: 1,
           startPanel: AnimationListContainer(
             {
+              addAnimationsCurrentTimeListener,
               animations,
               emitEventForTest,
               getAnimatedPropertyMap,
               getNodeFromActor,
               onHideBoxModelHighlighter,
               onShowBoxModelHighlighterForNode,
+              removeAnimationsCurrentTimeListener,
               selectAnimation,
+              setAnimationsCurrentTime,
               setSelectedNode,
               simulateAnimation,
               timeScale,
             }
           ),
           vert: false,
         })
       ]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/components/CurrentTimeScrubber.js
@@ -0,0 +1,32 @@
+/* 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");
+
+class CurrentTimeScrubber extends PureComponent {
+  static get propTypes() {
+    return {
+      offset: PropTypes.number.isRequired,
+    };
+  }
+
+  render() {
+    const { offset } = this.props;
+
+    return dom.div(
+      {
+        className: "current-time-scrubber",
+        style: {
+          transform: `translateX(${ offset }px)`,
+        },
+      }
+    );
+  }
+}
+
+module.exports = CurrentTimeScrubber;
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/animation/components/CurrentTimeScrubberController.js
@@ -0,0 +1,135 @@
+/* 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 CurrentTimeScrubber = createFactory(require("./CurrentTimeScrubber"));
+
+class CurrentTimeScrubberController extends PureComponent {
+  static get propTypes() {
+    return {
+      addAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+      removeAnimationsCurrentTimeListener: PropTypes.func.isRequired,
+      setAnimationsCurrentTime: PropTypes.func.isRequired,
+      timeScale: PropTypes.object.isRequired,
+    };
+  }
+
+  constructor(props) {
+    super(props);
+
+    const { addAnimationsCurrentTimeListener } = props;
+    this.onCurrentTimeUpdated = this.onCurrentTimeUpdated.bind(this);
+    this.onMouseDown = this.onMouseDown.bind(this);
+    this.onMouseMove = this.onMouseMove.bind(this);
+    this.onMouseOut = this.onMouseOut.bind(this);
+    this.onMouseUp = this.onMouseUp.bind(this);
+
+    this.state = {
+      // offset of the position for the scrubber
+      offset: 0,
+    };
+
+    addAnimationsCurrentTimeListener(this.onCurrentTimeUpdated);
+  }
+
+  componentDidMount() {
+    const parentEl = ReactDOM.findDOMNode(this).parentElement;
+    parentEl.addEventListener("mousedown", this.onMouseDown);
+  }
+
+  componentWillUnmount() {
+    const { removeAnimationsCurrentTimeListener } = this.props;
+    removeAnimationsCurrentTimeListener(this.onCurrentTimeUpdated);
+  }
+
+  onCurrentTimeUpdated(currentTime) {
+    const { timeScale } = this.props;
+
+    const thisEl = ReactDOM.findDOMNode(this);
+    const offset =
+      thisEl ? currentTime / timeScale.getDuration() * thisEl.clientWidth : 0;
+    this.setState({ offset });
+  }
+
+  onMouseDown(e) {
+    const thisEl = ReactDOM.findDOMNode(this);
+    this.controllerArea = thisEl.getBoundingClientRect();
+    this.listenerTarget = thisEl.closest(".animation-list-container");
+    this.listenerTarget.addEventListener("mousemove", this.onMouseMove);
+    this.listenerTarget.addEventListener("mouseout", this.onMouseOut);
+    this.listenerTarget.addEventListener("mouseup", this.onMouseUp);
+    this.listenerTarget.classList.add("active-scrubber");
+
+    this.updateAnimationsCurrentTime(e.pageX, true);
+  }
+
+  onMouseMove(e) {
+    this.isMouseMoved = true;
+    this.updateAnimationsCurrentTime(e.pageX);
+  }
+
+  onMouseOut(e) {
+    if (!this.listenerTarget.contains(e.relatedTarget)) {
+      const endX = this.controllerArea.x + this.controllerArea.width;
+      const pageX = endX < e.pageX ? endX : e.pageX;
+      this.updateAnimationsCurrentTime(pageX, true);
+      this.uninstallListeners();
+    }
+  }
+
+  onMouseUp(e) {
+    if (this.isMouseMoved) {
+      this.updateAnimationsCurrentTime(e.pageX, true);
+      this.isMouseMoved = null;
+    }
+
+    this.uninstallListeners();
+  }
+
+  uninstallListeners() {
+    this.listenerTarget.removeEventListener("mousemove", this.onMouseMove);
+    this.listenerTarget.removeEventListener("mouseout", this.onMouseOut);
+    this.listenerTarget.removeEventListener("mouseup", this.onMouseUp);
+    this.listenerTarget.classList.remove("active-scrubber");
+    this.listenerTarget = null;
+    this.controllerArea = null;
+  }
+
+  updateAnimationsCurrentTime(pageX, needRefresh) {
+    const {
+      setAnimationsCurrentTime,
+      timeScale,
+    } = this.props;
+
+    const time = pageX - this.controllerArea.x < 0 ?
+                   0 :
+                   (pageX - this.controllerArea.x) /
+                     this.controllerArea.width * timeScale.getDuration();
+
+    setAnimationsCurrentTime(time, needRefresh);
+  }
+
+  render() {
+    const { offset } = this.state;
+
+    return dom.div(
+      {
+        className: "current-time-scrubber-controller devtools-toolbar",
+      },
+      CurrentTimeScrubber(
+        {
+          offset,
+        }
+      )
+    );
+  }
+}
+
+module.exports = CurrentTimeScrubberController;
--- a/devtools/client/inspector/animation/components/moz.build
+++ b/devtools/client/inspector/animation/components/moz.build
@@ -20,15 +20,17 @@ DevToolsModules(
     'AnimationListContainer.js',
     'AnimationListHeader.js',
     'AnimationTarget.js',
     'AnimationTimelineTickItem.js',
     'AnimationTimelineTickList.js',
     'AnimationToolbar.js',
     'App.js',
     'CurrentTimeLabel.js',
+    'CurrentTimeScrubber.js',
+    'CurrentTimeScrubberController.js',
     'KeyframesProgressTickItem.js',
     'KeyframesProgressTickList.js',
     'NoAnimationPanel.js',
     'PauseResumeButton.js',
     'PlaybackRateSelector.js',
     'RewindButton.js',
 )
--- a/devtools/client/themes/animation.css
+++ b/devtools/client/themes/animation.css
@@ -11,16 +11,17 @@
   --fill-color-cssanimation: var(--theme-contrast-background);
   --fill-color-csstransition: var(--theme-highlight-blue);
   --fill-color-scriptanimation: var(--theme-graphs-green);
   --graph-right-offset: 10px;
   --keyframe-marker-shadow-color: #c4c4c4;
   --pause-image: url(chrome://devtools/skin/images/pause.svg);
   --resume-image: url(chrome://devtools/skin/images/play.svg);
   --rewind-image: url(chrome://devtools/skin/images/rewind.svg);
+  --scrubber-color: #dd00a9;
   --sidebar-width: 200px;
   --stroke-color-cssanimation: var(--theme-highlight-lightorange);
   --stroke-color-csstransition: var(--theme-highlight-bluegrey);
   --stroke-color-scriptanimation: var(--theme-highlight-green);
   --tick-line-style: 0.5px solid rgba(128, 136, 144, 0.5);
 }
 
 :root.theme-dark {
@@ -90,38 +91,79 @@ select.playback-rate-selector.devtools-b
 
 /* Animation List Container */
 .animation-list-container {
   display: flex;
   flex-direction: column;
   height: 100%;
   overflow: hidden;
   width: 100%;
+  -moz-user-select: none;
+}
+
+.animation-list-container.active-scrubber {
+  cursor: col-resize;
 }
 
 /* Animation List Header */
 .animation-list-header {
-  display: flex;
-  justify-content: flex-end;
+  display: grid;
+  grid-template-columns: var(--sidebar-width) calc(100% - var(--sidebar-width) - var(--graph-right-offset)) var(--graph-right-offset);
   padding: 0;
 }
 
 /* Animation Timeline Tick List */
 .animation-timeline-tick-list {
-  margin-right: var(--graph-right-offset);
+  grid-column: 2/3;
   position: relative;
-  width: calc(100% - var(--sidebar-width) - var(--graph-right-offset));
 }
 
 .animation-timeline-tick-item {
   border-left: var(--tick-line-style);
   height: 100vh;
+  pointer-events: none;
   position: absolute;
 }
 
+/* Current Time Scrubber */
+.current-time-scrubber-controller {
+  cursor: col-resize;
+  grid-column: 2 / 3;
+  padding: 0;
+}
+
+.current-time-scrubber {
+  cursor: col-resize;
+  height: 100vh;
+  margin-left: -6px;
+  position: absolute;
+  width: 12px;
+  z-index: 1;
+}
+
+.current-time-scrubber::before {
+  border-left: 5px solid transparent;
+  border-right: 5px solid transparent;
+  border-top: 5px solid var(--scrubber-color);
+  content: "";
+  position: absolute;
+  top: 0;
+  width: 0;
+}
+
+.current-time-scrubber::after {
+  border-left: 1px solid var(--scrubber-color);
+  content: "";
+  height: 100%;
+  left: 5px;
+  position: absolute;
+  top: 0;
+  width: 0;
+}
+
 /* Animation List */
 .animation-list {
   flex: 1;
   list-style-type: none;
   margin: 0;
   overflow: auto;
   padding: 0;
 }
@@ -282,21 +324,23 @@ select.playback-rate-selector.devtools-b
   stroke-linejoin: round;
   stroke-opacity: .5;
   stroke-width: 4;
   text-anchor: end;
 }
 
 /* Animation Detail */
 .animation-detail-container {
+  background-color: var(--theme-body-background);
   display: flex;
   flex-direction: column;
   height: 100%;
   overflow: hidden;
   width: 100%;
+  z-index: 1;
 }
 
 .animation-detail-header {
   display: flex;
 }
 
 .animation-detail-title {
   flex: 1;