Bug 1366989 - Part 1: Avoid to refresh whole panel. r?pbro draft
authorDaisuke Akatsuka <dakatsuka@mozilla.com>
Tue, 19 Sep 2017 10:26:54 +0900
changeset 666619 97abed338415ce3209290230c0417d5f1780557d
parent 666583 948dc86aabc97e16d51ad19ea90debab6f93a628
child 666620 631dcebc00a1f12db0638913c9d163a98f4cc4e0
push id80475
push userbmo:dakatsuka@mozilla.com
push dateTue, 19 Sep 2017 02:05:07 +0000
reviewerspbro
bugs1366989
milestone57.0a1
Bug 1366989 - Part 1: Avoid to refresh whole panel. r?pbro Currently the animation inspector re-generates the entire animation timeline whenever an animation is added, changed, etc. To avoid this, averts to re-render the component which no needs. In this implementation, premises the actorID can be used as unique id for each animations. The mechanism is below. At initial time, renders all actors as normally. In this time, holds actorID and related components to componentsMap. Next, in case of that needs to update the UI, gets animation actors from server, and compares actorID of both the actors and componentsMap. If retrieved actorID exists in componentsMap, updates the view area only without re-rendering. For example, supposes, has an animation (actid-1) when opens the inspector, and a new animation (actid-2) was added a little later. At initial rendering, holds "actid-1” of first animation as key and related components to componentsMap. Next, when “actid-2” animation is added to document, can get animation actors that are “actid-1” and “actid-2” from server. Because “actid-1” is already held in componentsMap, updates “actid-1”’s view area. This is because TimeScale will be updated. Then "actid-2” render as normally since componentMap does not have the actorID. After rendered, holds “actid-2” and related components. However, even if actorID exists, if keyframes (tracks) and effect timing (state) differ, re-render that. Also, if iterationCount of effect timing represents Infinity, do re-rendering. Because the display area expands by the end of the currently displayed time. And, if actorID in componentsMap is not in retrieved actors, removes related components. MozReview-Commit-ID: GmifRX3GzYd
devtools/client/animationinspector/components/animation-time-block.js
devtools/client/animationinspector/components/animation-timeline.js
--- a/devtools/client/animationinspector/components/animation-time-block.js
+++ b/devtools/client/animationinspector/components/animation-time-block.js
@@ -53,48 +53,31 @@ AnimationTimeBlock.prototype = {
       this.containerEl.firstChild.remove();
     }
   },
 
   render: function (animation, tracks) {
     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 = createSVGNode({
       parent: this.containerEl,
       nodeType: "svg",
       attributes: {
         "class": "summary",
-        "preserveAspectRatio": "none",
-        "style": `left: ${ x - (state.delay > 0 ? delayW : 0) }%`
+        "preserveAspectRatio": "none"
       }
     });
-
-    // 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;
+    this.updateSummaryGraphViewBox(summaryEl);
 
-    // Set viewBox
-    summaryEl.setAttribute("viewBox",
-                           `${ state.delay < 0 ? state.delay : 0 }
-                            -${ 1 + strokeHeightForViewBox }
-                            ${ totalDisplayedDuration }
-                            ${ 1 + strokeHeightForViewBox * 2 }`);
-
+    const {state} = this.animation;
+    // Total displayed duration
+    const totalDisplayedDuration = this.getTotalDisplayedDuration();
     // Minimum segment duration is the duration of one pixel.
     const minSegmentDuration = totalDisplayedDuration / this.containerEl.clientWidth;
     // Minimum progress threshold for effect timing.
     const minEffectProgressThreshold = getPreferredProgressThreshold(state.easing);
 
     // Render summary graph.
     // The summary graph is constructed from keyframes's easing and effect timing.
     const graphHelper = new SummaryGraphHelper(this.win, state, minSegmentDuration);
@@ -126,43 +109,104 @@ AnimationTimeBlock.prototype = {
         "x": "100%",
       },
       textContent: state.name
     });
 
     // Delay.
     if (state.delay) {
       // Negative delays need to start at 0.
-      createNode({
+      const delayEl = createNode({
         parent: this.containerEl,
         attributes: {
           "class": "delay"
                    + (state.delay < 0 ? " negative" : " positive")
                    + (state.fill === "both" ||
-                      state.fill === "backwards" ? " fill" : ""),
-          "style": `left:${ delayX }%; width:${ delayW }%;`
+                      state.fill === "backwards" ? " fill" : "")
         }
       });
+      this.updateDelayBounds(delayEl);
     }
 
     // endDelay
     if (state.iterationCount && state.endDelay) {
-      createNode({
+      const endDelayEl = 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 }%;`
+                      state.fill === "forwards" ? " fill" : "")
         }
       });
+      this.updateEndDelayBounds(endDelayEl);
     }
   },
 
+  /**
+   * Update animation and updating its DOM accordingly.
+   * Unlike 'render' method, this method does not generate any elements, but update
+   * the bounds of DOM.
+   * @param {Object} animation
+   */
+  update: function (animation) {
+    this.animation = animation;
+    this.updateSummaryGraphViewBox(this.containerEl.querySelector(".summary"));
+    const delayEl = this.containerEl.querySelector(".delay");
+    if (delayEl) {
+      this.updateDelayBounds(delayEl);
+    }
+    const endDelayEl = this.containerEl.querySelector(".end-delay");
+    if (endDelayEl) {
+      this.updateEndDelayBounds(endDelayEl);
+    }
+  },
+
+  /**
+   * Update viewBox and style of SVG element for summary graph to fit to latest
+   * TimeScale.
+   * @param {Element} summaryEl - SVG element for summary graph.
+   */
+  updateSummaryGraphViewBox: function (summaryEl) {
+    const {x, delayW} = TimeScale.getAnimationDimensions(this.animation);
+    const totalDisplayedDuration = this.getTotalDisplayedDuration();
+    const strokeHeightForViewBox = 0.5 / this.containerEl.clientHeight;
+    const {state} = this.animation;
+    summaryEl.setAttribute("viewBox",
+                           `${state.delay < 0 ? state.delay : 0} ` +
+                           `-${1 + strokeHeightForViewBox} ` +
+                           `${totalDisplayedDuration} ` +
+                           `${1 + strokeHeightForViewBox * 2}`);
+    summaryEl.setAttribute("style", `left: ${ x - (state.delay > 0 ? delayW : 0) }%`);
+  },
+
+  /**
+   * Update bounds of element which represents delay to fit to latest TimeScale.
+   * @param {Element} delayEl - which represents delay.
+   */
+  updateDelayBounds: function (delayEl) {
+    const {delayX, delayW} = TimeScale.getAnimationDimensions(this.animation);
+    delayEl.style.left = `${ delayX }%`;
+    delayEl.style.width = `${ delayW }%`;
+  },
+
+  /**
+   * Update bounds of element which represents endDelay to fit to latest TimeScale.
+   * @param {Element} endDelayEl - which represents endDelay.
+   */
+  updateEndDelayBounds: function (endDelayEl) {
+    const {endDelayX, endDelayW} = TimeScale.getAnimationDimensions(this.animation);
+    endDelayEl.style.left = `${ endDelayX }%`;
+    endDelayEl.style.width = `${ endDelayW }%`;
+  },
+
+  getTotalDisplayedDuration: function () {
+    return this.animation.state.playbackRate * TimeScale.getDuration();
+  },
+
   getTooltipText: function (state) {
     let getTime = time => L10N.getFormatStr("player.timeLabel",
                                             L10N.numberWithDecimals(time / 1000, 2));
 
     let text = "";
 
     // Adding the name.
     text += getFormattedAnimationTitle({state});
--- a/devtools/client/animationinspector/components/animation-timeline.js
+++ b/devtools/client/animationinspector/components/animation-timeline.js
@@ -42,19 +42,17 @@ const TIMELINE_BACKGROUND_RESIZE_DEBOUNC
  * 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.tracksMap = new WeakMap();
-  this.targetNodes = [];
-  this.timeBlocks = [];
+  this.componentsMap = {};
   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);
@@ -241,17 +239,17 @@ AnimationsTimeline.prototype = {
       this.onScrubberMouseDown);
     this.scrubberHandleEl.removeEventListener("mousedown",
       this.onScrubberMouseDown);
     this.animationDetailCloseButton.removeEventListener("click",
       this.onDetailCloseButtonClick);
 
     this.rootWrapperEl.remove();
     this.animations = [];
-    this.tracksMap = null;
+    this.componentsMap = null;
     this.rootWrapperEl = null;
     this.timeHeaderEl = null;
     this.animationsEl = null;
     this.animatedPropertiesEl = null;
     this.scrubberEl = null;
     this.scrubberHandleEl = null;
     this.win = null;
     this.inspector = null;
@@ -262,46 +260,48 @@ AnimationsTimeline.prototype = {
     this.animationDetailCloseButton = null;
     this.animationRootEl = null;
     this.selectedAnimation = null;
 
     this.isDestroyed = true;
   },
 
   /**
-   * 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.
+   * Destroy all sub-components that have been created and stored on this instance.
    */
-  destroySubComponents: function (name, handlers = []) {
-    for (let component of this[name]) {
-      for (let {event, fn} of handlers) {
-        component.off(event, fn);
-      }
-      component.destroy();
+  destroyAllSubComponents: function () {
+    for (let actorID in this.componentsMap) {
+      this.destroySubComponents(actorID);
     }
-    this[name] = [];
+  },
+
+  /**
+   * Destroy sub-components which related to given actor id.
+   * @param {String} actor id
+   */
+  destroySubComponents: function (actorID) {
+    const components = this.componentsMap[actorID];
+    components.timeBlock.destroy();
+    components.targetNode.destroy();
+    components.animationEl.remove();
+    delete components.state;
+    delete components.tracks;
+    delete this.componentsMap[actorID];
   },
 
   unrender: function () {
-    this.unrenderButLeaveDetailsComponent();
-    this.details.unrender();
-  },
-
-  unrenderButLeaveDetailsComponent: function () {
     for (let animation of this.animations) {
       animation.off("changed", this.onAnimationStateChanged);
     }
     this.stopAnimatingScrubber();
     TimeScale.reset();
-    this.destroySubComponents("targetNodes");
-    this.destroySubComponents("timeBlocks");
+    this.destroyAllSubComponents();
     this.animationsEl.innerHTML = "";
     this.off("timeline-data-changed", this.onTimelineDataChanged);
+    this.details.unrender();
   },
 
   onWindowResize: function () {
     // Don't do anything if the root element has a width of 0
     if (this.rootWrapperEl.offsetWidth === 0) {
       return;
     }
 
@@ -345,17 +345,17 @@ AnimationsTimeline.prototype = {
 
     // Select and render.
     const selectedAnimationEl = animationEls[index];
     selectedAnimationEl.classList.add("selected");
     this.animationRootEl.classList.add("animation-detail-visible");
     // Don't render if the detail displays same animation already.
     if (animation !== this.details.animation) {
       this.selectedAnimation = animation;
-      yield this.details.render(animation, this.tracksMap.get(animation));
+      yield this.details.render(animation, this.componentsMap[animation.actorID].tracks);
       this.animationAnimationNameEl.textContent = getFormattedAnimationTitle(animation);
     }
     this.onTimelineDataChanged({ time: this.currentTime || 0 });
     this.emit("animation-selected", animation);
   }),
 
   /**
    * When move the scrubber to the corresponding position
@@ -430,81 +430,66 @@ AnimationsTimeline.prototype = {
         ? " some-properties"
         : " all-properties";
     }
 
     return className;
   },
 
   render: Task.async(function* (animations, documentCurrentTime) {
-    this.unrenderButLeaveDetailsComponent();
+    this.animations = animations;
 
-    this.animations = animations;
+    // Destroy components which are no longer existed in given animations.
+    for (let animation of this.animations) {
+      if (this.componentsMap[animation.actorID]) {
+        this.componentsMap[animation.actorID].needToLeave = true;
+      }
+    }
+    for (let actorID in this.componentsMap) {
+      const components = this.componentsMap[actorID];
+      if (components.needToLeave) {
+        delete components.needToLeave;
+      } else {
+        this.destroySubComponents(actorID);
+      }
+    }
+
     if (!this.animations.length) {
       this.emit("animation-timeline-rendering-completed");
       return;
     }
 
-    // Loop first to set the time scale for all current animations.
+    // Loop to set the time scale for all current animations.
+    TimeScale.reset();
     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)
-        }
-      });
 
-      // 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.
       const tracks = yield this.getTracks(animation);
       // If we're destroyed by now, just give up.
       if (this.isDestroyed) {
         return;
       }
 
-      let timeBlock = new AnimationTimeBlock();
-      timeBlock.init(timeBlockEl);
-      timeBlock.render(animation, tracks);
-      this.timeBlocks.push(timeBlock);
-      this.tracksMap.set(animation, tracks);
-
-      timeBlock.on("selected", this.onAnimationSelected);
+      if (this.componentsMap[animation.actorID]) {
+        // Update animation UI using existent components.
+        this.updateAnimation(animation, tracks, this.componentsMap[animation.actorID]);
+      } else {
+        // Render animation UI as new element.
+        const animationEl = createNode({
+          parent: this.animationsEl,
+          nodeType: "li",
+        });
+        this.renderAnimation(animation, tracks, animationEl);
+      }
     }
 
     // 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";
@@ -529,16 +514,76 @@ AnimationsTimeline.prototype = {
       yield this.onAnimationSelected(this.selectedAnimation);
     } else {
       // Otherwise, close detail pane.
       this.onDetailCloseButtonClick();
     }
     this.emit("animation-timeline-rendering-completed");
   }),
 
+  updateAnimation: function (animation, tracks, existentComponents) {
+    // If keyframes (tracks) and effect timing (state) are not changed, we update the
+    // view box only.
+    // As an exception, if iterationCount reprensents Infinity, we need to re-render
+    // the shape along new time scale.
+    // FIXME: To avoid re-rendering even Infinity, we need to change the
+    // representation for Infinity.
+    if (animation.state.iterationCount &&
+        areTimingEffectsEqual(existentComponents.state, animation.state) &&
+        existentComponents.tracks.toString() === tracks.toString()) {
+      // Update timeBlock.
+      existentComponents.timeBlock.update(animation);
+    } else {
+      // Destroy previous components.
+      existentComponents.timeBlock.destroy();
+      existentComponents.targetNode.destroy();
+      // Remove children to re-use.
+      existentComponents.animationEl.innerHTML = "";
+      // Re-render animation using existent animationEl.
+      this.renderAnimation(animation, tracks, existentComponents.animationEl);
+    }
+  },
+
+  renderAnimation: function (animation, tracks, animationEl) {
+    animationEl.setAttribute("class",
+                             "animation " + animation.state.type +
+                             this.getCompositorStatusClassName(animation.state));
+
+    // 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);
+
+    // 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, tracks);
+    timeBlock.on("selected", this.onAnimationSelected);
+
+    this.componentsMap[animation.actorID] = {
+      animationEl, targetNode, timeBlock, tracks, state: animation.state
+    };
+  },
+
   isAtLeastOneAnimationPlaying: function () {
     return this.animations.some(({state}) => state.playState === "running");
   },
 
   wasRewound: function () {
     return !this.isAtLeastOneAnimationPlaying() &&
            this.animations.every(({state}) => state.currentTime === 0);
   },
@@ -733,8 +778,25 @@ AnimationsTimeline.prototype = {
           });
         }
       }
     }
 
     return tracks;
   })
 };
+
+/**
+ * Check the equality given states as effect timing.
+ * @param {Object} state of animation.
+ * @param {Object} same to avobe.
+ * @return {boolean} true: same effect timing
+ */
+function areTimingEffectsEqual(stateA, stateB) {
+  for (const property of ["playbackRate", "duration", "delay", "endDelay",
+                          "iterationCount", "iterationStart", "easing",
+                          "fill", "direction"]) {
+    if (stateA[property] !== stateB[property]) {
+      return false;
+    }
+  }
+  return true;
+}