Bug 1211810 - Add a way to lock the highlighter on animated nodes from animation-inspector; r=tromey
authorPatrick Brosset <pbrosset@mozilla.com>
Fri, 09 Oct 2015 10:44:53 +0200
changeset 267055 f7a73a8c22ce0363f9da91a5b8568c8a5a665ba1
parent 267054 a666cafe449f96c244a70d87151cb357a3bead34
child 267056 e63e05692f4296badd191157ec66ae7d7b41ff0b
push id17976
push userkwierso@gmail.com
push dateFri, 09 Oct 2015 23:46:15 +0000
treeherderb2g-inbound@9f8c18106b11 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerstromey
bugs1211810
milestone44.0a1
Bug 1211810 - Add a way to lock the highlighter on animated nodes from animation-inspector; r=tromey
browser/locales/en-US/chrome/browser/devtools/animationinspector.properties
devtools/client/animationinspector/animation-panel.js
devtools/client/animationinspector/components.js
devtools/client/animationinspector/test/browser.ini
devtools/client/animationinspector/test/browser_animation_participate_in_inspector_update.js
devtools/client/animationinspector/test/browser_animation_playerWidgets_target_nodes.js
devtools/client/animationinspector/test/browser_animation_refresh_on_added_animation.js
devtools/client/animationinspector/test/browser_animation_target_highlight_select.js
devtools/client/animationinspector/test/browser_animation_target_highlighter_lock.js
devtools/client/animationinspector/utils.js
devtools/client/themes/animationinspector.css
--- a/browser/locales/en-US/chrome/browser/devtools/animationinspector.properties
+++ b/browser/locales/en-US/chrome/browser/devtools/animationinspector.properties
@@ -82,9 +82,23 @@ timeline.cssanimation.nameLabel=%S - CSS
 # %S will be replaced by the name of the transition at run-time.
 timeline.csstransition.nameLabel=%S - CSS Transition
 
 # LOCALIZATION NOTE (timeline.unknown.nameLabel):
 # This string is displayed in a tooltip of the animation panel that is shown
 # when hovering over the name of an unknown animation type in the timeline UI.
 # This can happen if devtools couldn't figure out the type of the animation.
 # %S will be replaced by the name of the transition at run-time.
-timeline.unknown.nameLabel=%S
\ No newline at end of file
+timeline.unknown.nameLabel=%S
+
+# LOCALIZATION NOTE (node.selectNodeLabel):
+# This string is displayed in a tooltip of the animation panel that is shown
+# when hovering over an animated node (e.g. something like div.animated).
+# The tooltip invites the user to click on the node in order to select it in the
+# inspector panel.
+node.selectNodeLabel=Click to select this node in the Inspector
+
+# LOCALIZATION NOTE (node.highlightNodeLabel):
+# This string is displayed in a tooltip of the animation panel that is shown
+# when hovering over the inspector icon displayed next to animated nodes.
+# The tooltip invites the user to click on the icon in order to show the node
+# highlighter.
+node.highlightNodeLabel=Click to highlight this node in the page
--- a/devtools/client/animationinspector/animation-panel.js
+++ b/devtools/client/animationinspector/animation-panel.js
@@ -73,18 +73,16 @@ var AnimationsPanel = {
     }
     this.destroyed = promise.defer();
 
     this.stopListeners();
 
     this.animationsTimelineComponent.destroy();
     this.animationsTimelineComponent = null;
 
-    yield this.destroyPlayerWidgets();
-
     this.playersEl = this.errorMessageEl = null;
     this.toggleAllButtonEl = this.pickerButtonEl = null;
     this.playTimelineButtonEl = null;
 
     this.destroyed.resolve();
   }),
 
   startListeners: function() {
@@ -182,17 +180,16 @@ var AnimationsPanel = {
     }
   },
 
   refreshAnimations: Task.async(function*() {
     let done = gInspector.updating("animationspanel");
 
     // Empty the whole panel first.
     this.togglePlayers(true);
-    yield this.destroyPlayerWidgets();
 
     // Re-render the timeline component.
     this.animationsTimelineComponent.render(
       AnimationsController.animationPlayers,
       AnimationsController.documentCurrentTime);
 
     // If there are no players to show, show the error message instead and
     // return.
@@ -200,23 +197,12 @@ var AnimationsPanel = {
       this.togglePlayers(false);
       this.emit(this.UI_UPDATED_EVENT);
       done();
       return;
     }
 
     this.emit(this.UI_UPDATED_EVENT);
     done();
-  }),
-
-  destroyPlayerWidgets: Task.async(function*() {
-    if (!this.playerWidgets) {
-      return;
-    }
-
-    let destroyers = this.playerWidgets.map(widget => widget.destroy());
-    yield promise.all(destroyers);
-    this.playerWidgets = null;
-    this.playersEl.innerHTML = "";
   })
 };
 
 EventEmitter.decorate(AnimationsPanel);
--- a/devtools/client/animationinspector/components.js
+++ b/devtools/client/animationinspector/components.js
@@ -20,17 +20,18 @@
 //    c.destroy();
 
 const {Cu} = require("chrome");
 Cu.import("resource:///modules/devtools/client/shared/widgets/ViewHelpers.jsm");
 const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
 const {
   createNode,
   drawGraphElementBackground,
-  findOptimalTimeInterval
+  findOptimalTimeInterval,
+  TargetNodeHighlighter
 } = require("devtools/client/animationinspector/utils");
 
 const STRINGS_URI = "chrome://browser/locale/devtools/animationinspector.properties";
 const L10N = new ViewHelpers.L10N(STRINGS_URI);
 const MILLIS_TIME_FORMAT_MAX_DURATION = 4000;
 // The minimum spacing between 2 time graduation headers in the timeline (px).
 const TIME_GRADUATION_MIN_SPACING = 40;
 
@@ -47,16 +48,18 @@ const TIME_GRADUATION_MIN_SPACING = 40;
 function AnimationTargetNode(inspector, options={}) {
   this.inspector = inspector;
   this.options = options;
 
   this.onPreviewMouseOver = this.onPreviewMouseOver.bind(this);
   this.onPreviewMouseOut = this.onPreviewMouseOut.bind(this);
   this.onSelectNodeClick = this.onSelectNodeClick.bind(this);
   this.onMarkupMutations = this.onMarkupMutations.bind(this);
+  this.onHighlightNodeClick = this.onHighlightNodeClick.bind(this);
+  this.onTargetHighlighterLocked = this.onTargetHighlighterLocked.bind(this);
 
   EventEmitter.decorate(this);
 }
 
 exports.AnimationTargetNode = AnimationTargetNode;
 
 AnimationTargetNode.prototype = {
   init: function(containerEl) {
@@ -66,28 +69,32 @@ AnimationTargetNode.prototype = {
     this.el = createNode({
       parent: containerEl,
       attributes: {
         "class": "animation-target"
       }
     });
 
     // Icon to select the node in the inspector.
-    this.selectNodeEl = createNode({
+    this.highlightNodeEl = createNode({
       parent: this.el,
       nodeType: "span",
       attributes: {
-        "class": "node-selector"
+        "class": "node-highlighter",
+        "title": L10N.getStr("node.highlightNodeLabel")
       }
     });
 
     // Wrapper used for mouseover/out event handling.
     this.previewEl = createNode({
       parent: this.el,
-      nodeType: "span"
+      nodeType: "span",
+      attributes: {
+        "title": L10N.getStr("node.selectNodeLabel")
+      }
     });
 
     if (!this.options.compact) {
       this.previewEl.appendChild(document.createTextNode("<"));
     }
 
     // Tag name.
     this.tagNameEl = createNode({
@@ -175,51 +182,90 @@ AnimationTargetNode.prototype = {
     if (!this.options.compact) {
       this.classEl.appendChild(document.createTextNode("\""));
       this.previewEl.appendChild(document.createTextNode(">"));
     }
 
     // Init events for highlighting and selecting the node.
     this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver);
     this.previewEl.addEventListener("mouseout", this.onPreviewMouseOut);
-    this.selectNodeEl.addEventListener("click", this.onSelectNodeClick);
+    this.previewEl.addEventListener("click", this.onSelectNodeClick);
+    this.highlightNodeEl.addEventListener("click", this.onHighlightNodeClick);
 
     // Start to listen for markupmutation events.
     this.inspector.on("markupmutation", this.onMarkupMutations);
+
+    // Listen to the target node highlighter.
+    TargetNodeHighlighter.on("highlighted", this.onTargetHighlighterLocked);
   },
 
   destroy: function() {
+    TargetNodeHighlighter.unhighlight().catch(e => console.error(e));
+
+    TargetNodeHighlighter.off("highlighted", this.onTargetHighlighterLocked);
     this.inspector.off("markupmutation", this.onMarkupMutations);
     this.previewEl.removeEventListener("mouseover", this.onPreviewMouseOver);
     this.previewEl.removeEventListener("mouseout", this.onPreviewMouseOut);
-    this.selectNodeEl.removeEventListener("click", this.onSelectNodeClick);
+    this.previewEl.removeEventListener("click", this.onSelectNodeClick);
+    this.highlightNodeEl.removeEventListener("click", this.onHighlightNodeClick);
+
     this.el.remove();
     this.el = this.tagNameEl = this.idEl = this.classEl = null;
-    this.selectNodeEl = this.previewEl = null;
+    this.highlightNodeEl = this.previewEl = null;
     this.nodeFront = this.inspector = this.playerFront = null;
   },
 
+  get highlighterUtils() {
+    return this.inspector.toolbox.highlighterUtils;
+  },
+
   onPreviewMouseOver: function() {
     if (!this.nodeFront) {
       return;
     }
-    this.inspector.toolbox.highlighterUtils.highlightNodeFront(this.nodeFront);
+    this.highlighterUtils.highlightNodeFront(this.nodeFront);
   },
 
   onPreviewMouseOut: function() {
-    this.inspector.toolbox.highlighterUtils.unhighlight();
+    if (!this.nodeFront) {
+      return;
+    }
+    this.highlighterUtils.unhighlight();
   },
 
   onSelectNodeClick: function() {
     if (!this.nodeFront) {
       return;
     }
     this.inspector.selection.setNodeFront(this.nodeFront, "animationinspector");
   },
 
+  onHighlightNodeClick: function() {
+    let classList = this.highlightNodeEl.classList;
+
+    let isHighlighted = classList.contains("selected");
+    if (isHighlighted) {
+      classList.remove("selected");
+      TargetNodeHighlighter.unhighlight().then(() => {
+        this.emit("target-highlighter-unlocked");
+      }, e => console.error(e));
+    } else {
+      classList.add("selected");
+      TargetNodeHighlighter.highlight(this).then(() => {
+        this.emit("target-highlighter-locked");
+      }, e => console.error(e));
+    }
+  },
+
+  onTargetHighlighterLocked: function(e, animationTargetNode) {
+    if (animationTargetNode !== this) {
+      this.highlightNodeEl.classList.remove("selected");
+    }
+  },
+
   onMarkupMutations: function(e, mutations) {
     if (!this.nodeFront || !this.playerFront) {
       return;
     }
 
     for (let {target} of mutations) {
       if (target === this.nodeFront) {
         // Re-render with the same nodeFront to update the output.
@@ -232,23 +278,24 @@ AnimationTargetNode.prototype = {
   render: Task.async(function*(playerFront) {
     this.playerFront = playerFront;
     this.nodeFront = undefined;
 
     try {
       this.nodeFront = yield this.inspector.walker.getNodeFromActor(
                              playerFront.actorID, ["node"]);
     } catch (e) {
-      // We might have been destroyed in the meantime, or the node might not be
-      // found.
       if (!this.el) {
+        // The panel was destroyed in the meantime. Just log a warning.
         console.warn("Cound't retrieve the animation target node, widget " +
                      "destroyed");
+      } else {
+        // This was an unexpected error, log it.
+        console.error(e);
       }
-      console.error(e);
       return;
     }
 
     if (!this.nodeFront || !this.el) {
       return;
     }
 
     let {tagName, attributes} = this.nodeFront;
--- a/devtools/client/animationinspector/test/browser.ini
+++ b/devtools/client/animationinspector/test/browser.ini
@@ -18,16 +18,17 @@ support-files =
 [browser_animation_playerWidgets_appear_on_panel_init.js]
 [browser_animation_playerWidgets_target_nodes.js]
 [browser_animation_refresh_on_added_animation.js]
 [browser_animation_refresh_on_removed_animation.js]
 [browser_animation_refresh_when_active.js]
 [browser_animation_same_nb_of_playerWidgets_and_playerFronts.js]
 [browser_animation_shows_player_on_valid_node.js]
 [browser_animation_target_highlight_select.js]
+[browser_animation_target_highlighter_lock.js]
 [browser_animation_timeline_header.js]
 [browser_animation_timeline_pause_button.js]
 [browser_animation_timeline_scrubber_exists.js]
 [browser_animation_timeline_scrubber_movable.js]
 [browser_animation_timeline_scrubber_moves.js]
 [browser_animation_timeline_shows_delay.js]
 [browser_animation_timeline_shows_iterations.js]
 [browser_animation_timeline_shows_time_info.js]
--- a/devtools/client/animationinspector/test/browser_animation_participate_in_inspector_update.js
+++ b/devtools/client/animationinspector/test/browser_animation_participate_in_inspector_update.js
@@ -5,39 +5,39 @@
 "use strict";
 
 // Test that the update of the animation panel participate in the
 // inspector-updated event. This means that the test verifies that the
 // inspector-updated event is emitted *after* the animation panel is ready.
 
 add_task(function*() {
   yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel, controller} = yield openAnimationInspector();
 
-  let ui = yield openAnimationInspector();
-  yield testEventsOrder(ui);
-});
-
-function* testEventsOrder({inspector, panel, controller}) {
   info("Listen for the players-updated, ui-updated and inspector-updated events");
   let receivedEvents = [];
   controller.once(controller.PLAYERS_UPDATED_EVENT, () => {
     receivedEvents.push(controller.PLAYERS_UPDATED_EVENT);
   });
   panel.once(panel.UI_UPDATED_EVENT, () => {
     receivedEvents.push(panel.UI_UPDATED_EVENT);
   });
   inspector.once("inspector-updated", () => {
     receivedEvents.push("inspector-updated");
   });
 
   info("Selecting an animated node");
   let node = yield getNodeFront(".animated", inspector);
   yield selectNode(node, inspector);
 
-  info("Check that all events were received, and in the right order");
+  info("Check that all events were received");
+  // Only assert that the inspector-updated event is last, the order of the
+  // first 2 events is irrelevant.
+
   is(receivedEvents.length, 3, "3 events were received");
-  is(receivedEvents[0], controller.PLAYERS_UPDATED_EVENT,
-    "The first event received was the players-updated event");
-  is(receivedEvents[1], panel.UI_UPDATED_EVENT,
-    "The second event received was the ui-updated event");
   is(receivedEvents[2], "inspector-updated",
-    "The third event received was the inspector-updated event");
-}
+     "The third event received was the inspector-updated event");
+
+  ok(receivedEvents.indexOf(controller.PLAYERS_UPDATED_EVENT) !== -1,
+     "The players-updated event was received");
+  ok(receivedEvents.indexOf(panel.UI_UPDATED_EVENT) !== -1,
+     "The ui-updated event was received");
+});
--- a/devtools/client/animationinspector/test/browser_animation_playerWidgets_target_nodes.js
+++ b/devtools/client/animationinspector/test/browser_animation_playerWidgets_target_nodes.js
@@ -18,12 +18,12 @@ add_task(function*() {
   // yet been retrieved by the TargetNodeComponent.
   if (!targetNodeComponent.nodeFront) {
     yield targetNodeComponent.once("target-retrieved");
   }
 
   is(targetNodeComponent.el.textContent, "div#.ball.animated",
     "The target element's content is correct");
 
-  let selectorEl = targetNodeComponent.el.querySelector(".node-selector");
-  ok(selectorEl,
-    "The icon to select the target element in the inspector exists");
+  let highlighterEl = targetNodeComponent.el.querySelector(".node-highlighter");
+  ok(highlighterEl,
+    "The icon to highlight the target element in the page exists");
 });
--- a/devtools/client/animationinspector/test/browser_animation_refresh_on_added_animation.js
+++ b/devtools/client/animationinspector/test/browser_animation_refresh_on_added_animation.js
@@ -3,43 +3,43 @@
  http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 // Test that the panel content refreshes when new animations are added.
 
 add_task(function*() {
   yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {inspector, panel} = yield openAnimationInspector();
 
-  let {inspector, panel} = yield openAnimationInspector();
-  yield testRefreshOnNewAnimation(inspector, panel);
-});
-
-function* testRefreshOnNewAnimation(inspector, panel) {
   info("Select a non animated node");
   yield selectNode(".still", inspector);
 
   assertAnimationsDisplayed(panel, 0);
 
-  info("Listen to the next UI update event");
-  let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
-
   info("Start an animation on the node");
-  yield executeInContent("devtools:test:setAttribute", {
+  yield changeElementAndWait({
     selector: ".still",
     attributeName: "class",
     attributeValue: "ball animated"
-  });
-
-  yield onPanelUpdated;
-  ok(true, "The panel update event was fired");
+  }, panel, inspector);
 
   assertAnimationsDisplayed(panel, 1);
 
   info("Remove the animation class on the node");
-  onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
-  yield executeInContent("devtools:test:setAttribute", {
+  yield changeElementAndWait({
     selector: ".ball.animated",
     attributeName: "class",
     attributeValue: "ball still"
-  });
-  yield onPanelUpdated;
-}
+  }, panel, inspector);
+
+  assertAnimationsDisplayed(panel, 0);
+});
+
+function* changeElementAndWait(options, panel, inspector) {
+  let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
+  let onInspectorUpdated = inspector.once("inspector-updated");
+
+  yield executeInContent("devtools:test:setAttribute", options);
+
+  yield promise.all([
+    onInspectorUpdated, onPanelUpdated, waitForAllAnimationTargets(panel)]);
+}
\ No newline at end of file
--- a/devtools/client/animationinspector/test/browser_animation_target_highlight_select.js
+++ b/devtools/client/animationinspector/test/browser_animation_target_highlight_select.js
@@ -5,23 +5,19 @@
 "use strict";
 
 // Test that the DOM element targets displayed in animation player widgets can
 // be used to highlight elements in the DOM and select them in the inspector.
 
 add_task(function*() {
   yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
 
-  let ui = yield openAnimationInspector();
-  yield testTargetNode(ui);
-});
+  let {toolbox, inspector, panel} = yield openAnimationInspector();
 
-function* testTargetNode({toolbox, inspector, panel}) {
   info("Select the simple animated node");
-
   let onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
   yield selectNode(".animated", inspector);
   yield onPanelUpdated;
 
   let targets = yield waitForAllAnimationTargets(panel);
   // Arbitrary select the first one
   let targetNodeComponent = targets[0];
 
@@ -31,17 +27,17 @@ function* testTargetNode({toolbox, inspe
   info("Listen to node-highlight event and mouse over the widget");
   let onHighlight = toolbox.once("node-highlight");
   EventUtils.synthesizeMouse(highlightingEl, 10, 5, {type: "mouseover"},
                              highlightingEl.ownerDocument.defaultView);
   let nodeFront = yield onHighlight;
 
   // Do not forget to mouseout, otherwise we get random mouseover event
   // when selecting another node, which triggers some requests in animation
-  // inspector
+  // inspector.
   EventUtils.synthesizeMouse(highlightingEl, 10, 5, {type: "mouseout"},
                              highlightingEl.ownerDocument.defaultView);
 
   ok(true, "The node-highlight event was fired");
   is(targetNodeComponent.nodeFront, nodeFront,
     "The highlighted node is the one stored on the animation widget");
   is(nodeFront.tagName, "DIV",
     "The highlighted node has the correct tagName");
@@ -53,24 +49,23 @@ function* testTargetNode({toolbox, inspe
   info("Select the body node in order to have the list of all animations");
   onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
   yield selectNode("body", inspector);
   yield onPanelUpdated;
 
   targets = yield waitForAllAnimationTargets(panel);
   targetNodeComponent = targets[0];
 
-  info("Click on the first animation widget's selector icon and wait for the " +
-    "selection to change");
+  info("Click on the first animated node component and wait for the " +
+       "selection to change");
   let onSelection = inspector.selection.once("new-node-front");
   onPanelUpdated = panel.once(panel.UI_UPDATED_EVENT);
-  let selectIconEl = targetNodeComponent.selectNodeEl;
-  EventUtils.sendMouseEvent({type: "click"}, selectIconEl,
-                            selectIconEl.ownerDocument.defaultView);
+  let nodeEl = targetNodeComponent.previewEl;
+  EventUtils.sendMouseEvent({type: "click"}, nodeEl,
+                            nodeEl.ownerDocument.defaultView);
   yield onSelection;
 
   is(inspector.selection.nodeFront, targetNodeComponent.nodeFront,
     "The selected node is the one stored on the animation widget");
 
   yield onPanelUpdated;
-
   yield waitForAllAnimationTargets(panel);
-}
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/animationinspector/test/browser_animation_target_highlighter_lock.js
@@ -0,0 +1,50 @@
+/* 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 the DOM element targets displayed in animation player widgets can
+// be used to highlight elements in the DOM and select them in the inspector.
+
+add_task(function*() {
+  yield addTab(TEST_URL_ROOT + "doc_simple_animation.html");
+  let {toolbox, inspector, panel} = yield openAnimationInspector();
+
+  let targets = panel.animationsTimelineComponent.targetNodes;
+
+  info("Click on the highlighter icon for the first animated node");
+  yield lockHighlighterOn(targets[0]);
+  ok(targets[0].highlightNodeEl.classList.contains("selected"),
+     "The highlighter icon is selected");
+
+  info("Click on the highlighter icon for the second animated node");
+  yield lockHighlighterOn(targets[1]);
+  ok(targets[1].highlightNodeEl.classList.contains("selected"),
+     "The highlighter icon is selected");
+  ok(!targets[0].highlightNodeEl.classList.contains("selected"),
+     "The highlighter icon for the first node is unselected");
+
+  info("Click again to unhighlight");
+  yield unlockHighlighterOn(targets[1]);
+  ok(!targets[1].highlightNodeEl.classList.contains("selected"),
+     "The highlighter icon for the second node is unselected");
+});
+
+function* lockHighlighterOn(targetComponent) {
+  let onLocked = targetComponent.once("target-highlighter-locked");
+  clickOnHighlighterIcon(targetComponent);
+  yield onLocked;
+}
+
+function* unlockHighlighterOn(targetComponent) {
+  let onUnlocked = targetComponent.once("target-highlighter-unlocked");
+  clickOnHighlighterIcon(targetComponent);
+  yield onUnlocked;
+}
+
+function clickOnHighlighterIcon(targetComponent) {
+  let lockEl = targetComponent.highlightNodeEl;
+  EventUtils.sendMouseEvent({type: "click"}, lockEl,
+                            lockEl.ownerDocument.defaultView);
+}
--- a/devtools/client/animationinspector/utils.js
+++ b/devtools/client/animationinspector/utils.js
@@ -1,16 +1,22 @@
 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* 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 {Cu} = require("chrome");
+const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
+var {loader} = Cu.import("resource://gre/modules/devtools/shared/Loader.jsm");
+loader.lazyRequireGetter(this, "EventEmitter",
+                               "devtools/shared/event-emitter");
+
 // How many times, maximum, can we loop before we find the optimal time
 // interval in the timeline graph.
 const OPTIMAL_TIME_INTERVAL_MAX_ITERS = 100;
 // Background time graduations should be multiple of this number of millis.
 const TIME_INTERVAL_MULTIPLE = 25;
 const TIME_INTERVAL_SCALES = 3;
 // The default minimum spacing between time graduations in px.
 const TIME_GRADUATION_MIN_SPACING = 10;
@@ -129,8 +135,45 @@ function findOptimalTimeInterval(timeSca
       timingStep *= 2;
       continue;
     }
     return scaledStep;
   }
 }
 
 exports.findOptimalTimeInterval = findOptimalTimeInterval;
+
+/**
+ * The TargetNodeHighlighter util is a helper for AnimationTargetNode components
+ * that is used to lock the highlighter on animated nodes in the page.
+ * It instantiates a new highlighter that is then shared amongst all instances
+ * of AnimationTargetNode. This is useful because that means showing the
+ * highlighter on one animated node will unhighlight the previously highlighted
+ * one, but will not interfere with the default inspector highlighter.
+ */
+var TargetNodeHighlighter = {
+  highlighter: null,
+  isShown: false,
+
+  highlight: Task.async(function*(animationTargetNode) {
+    if (!this.highlighter) {
+      let hUtils = animationTargetNode.inspector.toolbox.highlighterUtils;
+      this.highlighter = yield hUtils.getHighlighterByType("BoxModelHighlighter");
+    }
+
+    yield this.highlighter.show(animationTargetNode.nodeFront);
+    this.isShown = true;
+    this.emit("highlighted", animationTargetNode);
+  }),
+
+  unhighlight: Task.async(function*() {
+    if (!this.highlighter || !this.isShown) {
+      return;
+    }
+
+    yield this.highlighter.hide();
+    this.isShown = false;
+    this.emit("unhighlighted");
+  })
+};
+
+EventEmitter.decorate(TargetNodeHighlighter);
+exports.TargetNodeHighlighter = TargetNodeHighlighter;
--- a/devtools/client/themes/animationinspector.css
+++ b/devtools/client/themes/animationinspector.css
@@ -344,34 +344,36 @@ body {
 
 .animation-target {
   background-color: var(--theme-toolbar-background);
   padding: 1px 4px;
   box-sizing: border-box;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  cursor: pointer;
 }
 
 .animation-target .attribute-name {
   padding-left: 4px;
 }
 
-.animation-target .node-selector {
+.animation-target .node-highlighter {
   background: url("chrome://devtools/skin/themes/images/vview-open-inspector.png") no-repeat 0 0;
   padding-left: 16px;
   margin-right: 5px;
   cursor: pointer;
 }
 
-.animation-target .node-selector:hover {
+.animation-target .node-highlighter:hover {
   background-position: -32px 0;
 }
 
-.animation-target .node-selector:active {
+.animation-target .node-highlighter:active,
+.animation-target .node-highlighter.selected {
   background-position: -16px 0;
 }
 
 /* Animation title gutter, contains the name, duration, iteration */
 
 .animation-title {
   background-color: var(--theme-toolbar-background);
   border-bottom: 1px solid var(--theme-splitter-color);