Bug 1206420 - Display animated pseudo-elements in the animation-inspector; r=tromey draft
authorPatrick Brosset <pbrosset@mozilla.com>
Thu, 24 Mar 2016 10:56:51 +0100
changeset 344302 e86923a01b1d04b7dcfa4f1df1356fac06999a65
parent 344154 1438f8e8639506fe605ddcd6a4576dddb8112a06
child 516922 3c8dbc53f048279d276d035759ecc6f80eb03267
push id13790
push userpbrosset@mozilla.com
push dateThu, 24 Mar 2016 09:57:07 +0000
reviewerstromey
bugs1206420
milestone48.0a1
Bug 1206420 - Display animated pseudo-elements in the animation-inspector; r=tromey MozReview-Commit-ID: 5bMOxKG6pMm
devtools/client/animationinspector/animation-controller.js
devtools/client/animationinspector/animation-panel.js
devtools/client/animationinspector/test/browser.ini
devtools/client/animationinspector/test/browser_animation_empty_on_invalid_nodes.js
devtools/client/animationinspector/test/browser_animation_pseudo_elements.js
devtools/client/animationinspector/test/doc_pseudo_elements.html
devtools/client/animationinspector/test/doc_simple_animation.html
devtools/client/inspector/shared/dom-node-preview.js
devtools/client/locales/en-US/animationinspector.properties
devtools/server/actors/animation.js
--- a/devtools/client/animationinspector/animation-controller.js
+++ b/devtools/client/animationinspector/animation-controller.js
@@ -225,18 +225,17 @@ var AnimationsController = {
         this.nodeFront === gInspector.selection.nodeFront) {
       return;
     }
 
     this.nodeFront = gInspector.selection.nodeFront;
     let done = gInspector.updating("animationscontroller");
 
     if (!gInspector.selection.isConnected() ||
-        !gInspector.selection.isElementNode() ||
-        gInspector.selection.isPseudoElementNode()) {
+        !gInspector.selection.isElementNode()) {
       this.destroyAnimationPlayers();
       this.emit(this.PLAYERS_UPDATED_EVENT);
       done();
       return;
     }
 
     yield this.refreshAnimationPlayers(this.nodeFront);
     this.emit(this.PLAYERS_UPDATED_EVENT, this.animationPlayers);
--- a/devtools/client/animationinspector/animation-panel.js
+++ b/devtools/client/animationinspector/animation-panel.js
@@ -178,19 +178,18 @@ var AnimationsPanel = {
 
   togglePlayers: function(isVisible) {
     if (isVisible) {
       document.body.removeAttribute("empty");
       document.body.setAttribute("timeline", "true");
     } else {
       document.body.setAttribute("empty", "true");
       document.body.removeAttribute("timeline");
-      $("#error-type").textContent = gInspector.selection.isPseudoElementNode()
-        ? L10N.getStr("panel.pseudoElementSelected")
-        : L10N.getStr("panel.invalidElementSelected");
+      $("#error-type").textContent =
+        L10N.getStr("panel.invalidElementSelected");
     }
   },
 
   onPickerStarted: function() {
     this.pickerButtonEl.setAttribute("checked", "true");
   },
 
   onPickerStopped: function() {
--- a/devtools/client/animationinspector/test/browser.ini
+++ b/devtools/client/animationinspector/test/browser.ini
@@ -3,16 +3,17 @@ tags = devtools
 subsuite = devtools
 skip-if = e10s && debug # bug 1252283
 support-files =
   doc_body_animation.html
   doc_frame_script.js
   doc_keyframes.html
   doc_modify_playbackRate.html
   doc_negative_animation.html
+  doc_pseudo_elements.html
   doc_simple_animation.html
   doc_multiple_animation_types.html
   head.js
 
 [browser_animation_animated_properties_displayed.js]
 [browser_animation_click_selects_animation.js]
 [browser_animation_controller_exposes_document_currentTime.js]
 skip-if = os == "linux" && !debug # Bug 1234567
@@ -20,16 +21,17 @@ skip-if = os == "linux" && !debug # Bug 
 [browser_animation_keyframe_click_to_set_time.js]
 [browser_animation_keyframe_markers.js]
 [browser_animation_mutations_with_same_names.js]
 [browser_animation_panel_exists.js]
 [browser_animation_participate_in_inspector_update.js]
 [browser_animation_playerFronts_are_refreshed.js]
 [browser_animation_playerWidgets_appear_on_panel_init.js]
 [browser_animation_playerWidgets_target_nodes.js]
+[browser_animation_pseudo_elements.js]
 [browser_animation_refresh_on_added_animation.js]
 [browser_animation_refresh_on_removed_animation.js]
 skip-if = os == "linux" && !debug # Bug 1227792
 [browser_animation_refresh_when_active.js]
 [browser_animation_running_on_compositor.js]
 [browser_animation_same_nb_of_playerWidgets_and_playerFronts.js]
 [browser_animation_shows_player_on_valid_node.js]
 [browser_animation_spacebar_toggles_animations.js]
--- a/devtools/client/animationinspector/test/browser_animation_empty_on_invalid_nodes.js
+++ b/devtools/client/animationinspector/test/browser_animation_empty_on_invalid_nodes.js
@@ -39,26 +39,9 @@ add_task(function*() {
 
   is(panel.animationsTimelineComponent.animations.length, 0,
      "No animation players stored in the timeline component for a text node");
   is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 0,
      "No animation displayed in the timeline component for a text node");
   is(document.querySelector("#error-type").textContent,
      L10N.getStr("panel.invalidElementSelected"),
      "The correct error message is displayed");
-
-  info("Select the pseudo element node and check that the panel is empty " +
-       "and contains the special animated pseudo-element message");
-  let pseudoElParent = yield getNodeFront(".pseudo", inspector);
-  let {nodes} = yield inspector.walker.children(pseudoElParent);
-  let pseudoEl = nodes[0];
-  onUpdated = panel.once(panel.UI_UPDATED_EVENT);
-  yield selectNode(pseudoEl, inspector);
-  yield onUpdated;
-
-  is(panel.animationsTimelineComponent.animations.length, 0,
-     "No animation players stored in the timeline component for a pseudo-node");
-  is(panel.animationsTimelineComponent.animationsEl.childNodes.length, 0,
-     "No animation displayed in the timeline component for a pseudo-node");
-  is(document.querySelector("#error-type").textContent,
-     L10N.getStr("panel.pseudoElementSelected"),
-     "The correct error message is displayed");
 });
new file mode 100644
--- /dev/null
+++ b/devtools/client/animationinspector/test/browser_animation_pseudo_elements.js
@@ -0,0 +1,49 @@
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that animated pseudo-elements do show in the timeline.
+
+add_task(function*() {
+  yield addTab(URL_ROOT + "doc_pseudo_elements.html");
+  let {inspector, panel} = yield openAnimationInspector();
+  let timeline = panel.animationsTimelineComponent;
+
+  info("With <body> selected by default check the content of the timeline");
+  is(timeline.timeBlocks.length, 3, "There are 3 animations in the timeline");
+
+  let getTargetNodeText = index => {
+    let el = timeline.targetNodes[index].previewer.previewEl;
+    return [...el.childNodes]
+           .map(n => n.style.display === "none" ? "" : n.textContent)
+           .join("");
+  };
+
+  is(getTargetNodeText(0), "body", "The first animated node is <body>");
+  is(getTargetNodeText(1), "::before", "The second animated node is ::before");
+  is(getTargetNodeText(2), "::after", "The third animated node is ::after");
+
+  info("Getting the before and after nodeFronts");
+  let bodyContainer = yield getContainerForSelector("body", inspector);
+  let getBodyChildNodeFront = index => {
+    return bodyContainer.elt.children[1].childNodes[index].container.node;
+  };
+  let beforeNode = getBodyChildNodeFront(0);
+  let afterNode = getBodyChildNodeFront(1);
+
+  info("Select the ::before pseudo-element in the inspector");
+  yield selectNode(beforeNode, inspector);
+  is(timeline.timeBlocks.length, 1, "There is 1 animation in the timeline");
+  is(timeline.targetNodes[0].previewer.nodeFront,
+     inspector.selection.nodeFront,
+     "The right node front is displayed in the timeline");
+
+  info("Select the ::after pseudo-element in the inspector");
+  yield selectNode(afterNode, inspector);
+  is(timeline.timeBlocks.length, 1, "There is 1 animation in the timeline");
+  is(timeline.targetNodes[0].previewer.nodeFront,
+     inspector.selection.nodeFront,
+     "The right node front is displayed in the timeline");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/animationinspector/test/doc_pseudo_elements.html
@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="UTF-8">
+    <title>Animated pseudo elements</title>
+    <style>
+      html, body {
+        margin: 0;
+        height: 100%;
+        width: 100%;
+        overflow: hidden;
+        display: flex;
+        justify-content: center;
+        align-items: flex-end;
+      }
+
+      body {
+        animation: color 2s linear infinite;
+        background: #333;
+      }
+
+      @keyframes color {
+        to {
+          filter: hue-rotate(360deg);
+        }
+      }
+
+      body::before,
+      body::after {
+        content: "";
+        flex-grow: 1;
+        height: 100%;
+        animation: grow 1s linear infinite alternate;
+      }
+
+      body::before {
+        background: hsl(120, 80%, 80%);
+      }
+      body::after {
+        background: hsl(240, 80%, 80%);
+        animation-delay: -.5s;
+      }
+
+      @keyframes grow {
+        0% {height: 100%; animation-timing-function: ease-in-out;}
+        10% {height: 80%; animation-timing-function: ease-in-out;}
+        20% {height: 60%; animation-timing-function: ease-in-out;}
+        30% {height: 70%; animation-timing-function: ease-in-out;}
+        40% {height: 50%; animation-timing-function: ease-in-out;}
+        50% {height: 30%; animation-timing-function: ease-in-out;}
+        60% {height: 80%; animation-timing-function: ease-in-out;}
+        70% {height: 90%; animation-timing-function: ease-in-out;}
+        80% {height: 70%; animation-timing-function: ease-in-out;}
+        90% {height: 60%; animation-timing-function: ease-in-out;}
+        100% {height: 100%; animation-timing-function: ease-in-out;}
+      }
+    </style>
+  </head>
+  <body>
+  </body>
+</html>
\ No newline at end of file
--- a/devtools/client/animationinspector/test/doc_simple_animation.html
+++ b/devtools/client/animationinspector/test/doc_simple_animation.html
@@ -77,31 +77,16 @@
     .no-compositor {
       top: 0;
       right: 10px;
       background: gold;
 
       animation: no-compositor 10s cubic-bezier(.57,-0.02,1,.31) forwards;
     }
 
-    .pseudo {
-      top: 800px;
-      left: 10px;
-    }
-
-    .pseudo::before {
-      content: "";
-      width: 50%;
-      height: 50%;
-      border-radius: 50%;
-      background: black;
-      position: absolute;
-      animation: simple-animation 1s infinite alternate;
-    }
-
     @keyframes simple-animation {
       100% {
         transform: translateX(300px);
       }
     }
 
     @keyframes other-animation {
       100% {
@@ -122,11 +107,10 @@
   <div class="ball animated"></div>
   <div class="ball multi"></div>
   <div class="ball delayed"></div>
   <div class="ball multi-finite"></div>
   <div class="ball short"></div>
   <div class="ball long"></div>
   <div class="ball negative-delay"></div>
   <div class="ball no-compositor"></div>
-  <div class="ball pseudo"></div>
 </body>
 </html>
--- a/devtools/client/inspector/shared/dom-node-preview.js
+++ b/devtools/client/inspector/shared/dom-node-preview.js
@@ -68,16 +68,25 @@ DomNodePreview.prototype = {
         "title": L10N.getStr("inspector.nodePreview.selectNodeLabel")
       }
     });
 
     if (!this.options.compact) {
       this.previewEl.appendChild(document.createTextNode("<"));
     }
 
+    // Only used for ::before and ::after pseudo-elements.
+    this.pseudoEl = createNode({
+      parent: this.previewEl,
+      nodeType: "span",
+      attributes: {
+        "class": "pseudo-element theme-fg-color5"
+      }
+    });
+
     // Tag name.
     this.tagNameEl = createNode({
       parent: this.previewEl,
       nodeType: "span",
       attributes: {
         "class": "tag-name theme-fg-color3"
       }
     });
@@ -188,17 +197,17 @@ DomNodePreview.prototype = {
   },
 
   destroy: function() {
     HighlighterLock.unhighlight().catch(e => console.error(e));
 
     this.stopListeners();
 
     this.el.remove();
-    this.el = this.tagNameEl = this.idEl = this.classEl = null;
+    this.el = this.tagNameEl = this.idEl = this.classEl = this.pseudoEl = null;
     this.highlightNodeEl = this.previewEl = null;
     this.nodeFront = this.inspector = null;
   },
 
   get highlighterUtils() {
     if (this.inspector && this.inspector.toolbox) {
       return this.inspector.toolbox.highlighterUtils;
     }
@@ -266,17 +275,27 @@ DomNodePreview.prototype = {
       }
     }
   },
 
   render: function(nodeFront) {
     this.nodeFront = nodeFront;
     let {tagName, attributes} = nodeFront;
 
-    this.tagNameEl.textContent = tagName.toLowerCase();
+    if (nodeFront.isPseudoElement) {
+      this.pseudoEl.textContent = nodeFront.isBeforePseudoElement
+                                   ? "::before"
+                                   : "::after";
+      this.pseudoEl.style.display = "inline";
+      this.tagNameEl.style.display = "none";
+    } else {
+      this.tagNameEl.textContent = tagName.toLowerCase();
+      this.pseudoEl.style.display = "none";
+      this.tagNameEl.style.display = "inline";
+    }
 
     let idIndex = attributes.findIndex(({name}) => name === "id");
     if (idIndex > -1 && attributes[idIndex].value) {
       this.idEl.querySelector(".attribute-value").textContent =
         attributes[idIndex].value;
       this.idEl.style.display = "inline";
     } else {
       this.idEl.style.display = "none";
--- a/devtools/client/locales/en-US/animationinspector.properties
+++ b/devtools/client/locales/en-US/animationinspector.properties
@@ -11,22 +11,16 @@
 # documentation on web development on the web.
 
 # LOCALIZATION NOTE (panel.invalidElementSelected):
 # This is the label shown in the panel when an invalid node is currently
 # selected in the inspector (i.e. a non-element node or a node that is not
 # animated).
 panel.invalidElementSelected=No animations were found for the current element.
 
-# LOCALIZATION NOTE (panel.pseudoElementSelected):
-# This is the label shown in the panel when a pseudo-element is currently
-# selected in the inspector (pseudo-elements can be animated, but the tool
-# doesn't yet support them).
-panel.pseudoElementSelected=Animated pseudo-elements are not supported yet.
-
 # LOCALIZATION NOTE (player.animationNameLabel):
 # This string is displayed in each animation player widget. It is the label
 # displayed before the animation name.
 player.animationNameLabel=Animation:
 
 # LOCALIZATION NOTE (player.transitionNameLabel):
 # This string is displayed in each animation player widget. It is the label
 # displayed in the header, when the element is animated by mean of a css
--- a/devtools/server/actors/animation.js
+++ b/devtools/server/actors/animation.js
@@ -69,35 +69,75 @@ var AnimationPlayerActor = ActorClass({
    */
   initialize: function(animationsActor, player) {
     Actor.prototype.initialize.call(this, animationsActor.conn);
 
     this.onAnimationMutation = this.onAnimationMutation.bind(this);
 
     this.walker = animationsActor.walker;
     this.player = player;
-    this.node = player.effect.target;
 
     // Listen to animation mutations on the node to alert the front when the
     // current animation changes.
+    // If the node is a pseudo-element, then we listen on its parent with
+    // subtree:true (there's no risk of getting too many notifications in
+    // onAnimationMutation since we filter out events that aren't for the
+    // current animation).
     this.observer = new this.window.MutationObserver(this.onAnimationMutation);
-    this.observer.observe(this.node, {animations: true});
+    if (this.isPseudoElement) {
+      this.observer.observe(this.node.parentElement,
+                            {animations: true, subtree: true});
+    } else {
+      this.observer.observe(this.node, {animations: true});
+    }
   },
 
   destroy: function() {
     // Only try to disconnect the observer if it's not already dead (i.e. if the
     // container view hasn't navigated since).
     if (this.observer && !Cu.isDeadWrapper(this.observer)) {
       this.observer.disconnect();
     }
-    this.player = this.node = this.observer = this.walker = null;
+    this.player = this.observer = this.walker = null;
 
     Actor.prototype.destroy.call(this);
   },
 
+  get isPseudoElement() {
+    return !this.player.effect.target.ownerDocument;
+  },
+
+  get node() {
+    if (this._node) {
+      return this._node;
+    }
+
+    let node = this.player.effect.target;
+
+    if (this.isPseudoElement) {
+      // The target is a CSSPseudoElement object which just has a property that
+      // points to its parent element and a string type (::before or ::after).
+      let treeWalker = this.walker.getDocumentWalker(node.parentElement);
+      while (treeWalker.nextNode()) {
+        let currentNode = treeWalker.currentNode;
+        if ((currentNode.nodeName === "_moz_generated_content_before" &&
+             node.type === "::before") ||
+            (currentNode.nodeName === "_moz_generated_content_after" &&
+             node.type === "::after")) {
+          this._node = currentNode;
+        }
+      }
+    } else {
+      // The target is a DOM node.
+      this._node = node;
+    }
+
+    return this._node;
+  },
+
   get window() {
     return this.node.ownerDocument.defaultView;
   },
 
   /**
    * Release the actor, when it isn't needed anymore.
    * Protocol.js uses this release method to call the destroy method.
    */
@@ -564,20 +604,17 @@ var AnimationsActor = exports.Animations
    * Note that calling this method a second time will destroy all previously
    * retrieved AnimationPlayerActors. Indeed, the lifecycle of these actors
    * is managed here on the server and tied to getAnimationPlayersForNode
    * being called.
    * @param {NodeActor} nodeActor The NodeActor as defined in
    * /devtools/server/actors/inspector
    */
   getAnimationPlayersForNode: method(function(nodeActor) {
-    let animations = [
-      ...nodeActor.rawNode.getAnimations(),
-      ...this.getAllAnimations(nodeActor.rawNode)
-    ];
+    let animations = nodeActor.rawNode.getAnimations({subtree: true});
 
     // Destroy previously stored actors
     if (this.actors) {
       this.actors.forEach(actor => actor.destroy());
     }
     this.actors = [];
 
     for (let i = 0; i < animations.length; i++) {
@@ -617,50 +654,34 @@ var AnimationsActor = exports.Animations
         // actually removed from the node (e.g. css class removed) or when they
         // are finished and don't have forwards animation-fill-mode.
         // In the latter case, we don't send an event, because the corresponding
         // animation can still be seeked/resumed, so we want the client to keep
         // its reference to the AnimationPlayerActor.
         if (player.playState !== "idle") {
           continue;
         }
-        // FIXME: In bug 1249219, we support the animation mutation for pseudo
-        // elements. However, the timeline may not be ready yet to
-        // display those correctly. Therefore, we add this check to bails out if
-        // the mutation target is a pseudo-element.
-        // Note. Only CSSPseudoElement object has |type| attribute, so if type
-        // exists, it is a CSSPseudoElement object.
-        if (player.effect.target.type) {
-          continue;
-        }
+
         let index = this.actors.findIndex(a => a.player === player);
         if (index !== -1) {
           eventData.push({
             type: "removed",
             player: this.actors[index]
           });
           this.actors.splice(index, 1);
         }
       }
 
       for (let player of addedAnimations) {
         // If the added player already exists, it means we previously filtered
         // it out when it was reported as removed. So filter it out here too.
         if (this.actors.find(a => a.player === player)) {
           continue;
         }
-        // FIXME: In bug 1249219, we support the animation mutation for pseudo
-        // elements. However, the timeline may not be ready yet to
-        // display those correctly. Therefore, we add this check to bails out if
-        // the mutation target is a pseudo-element.
-        // Note. Only CSSPseudoElement object has |type| attribute, so if type
-        // exists, it is a CSSPseudoElement object.
-        if (player.effect.target.type) {
-          continue;
-        }
+
         // If the added player has the same name and target node as a player we
         // already have, it means it's a transition that's re-starting. So send
         // a "removed" event for the one we already have.
         let index = this.actors.findIndex(a => {
           let isSameType = a.player.constructor === player.constructor;
           let isSameName = (a.isCssAnimation() &&
                             a.player.animationName === player.animationName) ||
                            (a.isCssTransition() &&
@@ -715,35 +736,24 @@ var AnimationsActor = exports.Animations
    * nested frames) and finds all existing animation players.
    * @param {DOMNode} rootNode The root node to start iterating at. Animation
    * players will *not* be reported for this node.
    * @param {Boolean} traverseFrames Whether we should iterate through nested
    * frames too.
    * @return {Array} An array of AnimationPlayer objects.
    */
   getAllAnimations: function(rootNode, traverseFrames) {
-    let animations = [];
-
-    // These loops shouldn't be as bad as they look.
-    // Typically, there will be very few nested frames, and getElementsByTagName
-    // is really fast even on large DOM trees.
-    for (let element of rootNode.getElementsByTagNameNS("*", "*")) {
-      if (traverseFrames && element.contentWindow) {
-        animations = [
-          ...animations,
-          ...this.getAllAnimations(element.contentWindow.document, traverseFrames)
-        ];
-      } else {
-        animations = [
-          ...animations,
-          ...element.getAnimations()
-        ];
-      }
+    if (!traverseFrames) {
+      return rootNode.getAnimations({subtree: true});
     }
 
+    let animations = [];
+    for (let {document} of this.tabActor.windows) {
+      animations = [...animations, ...document.getAnimations({subtree: true})];
+    }
     return animations;
   },
 
   onWillNavigate: function({isTopLevel}) {
     if (isTopLevel) {
       this.stopAnimationPlayerUpdates();
     }
   },