Bug 1349275 - Refactor `moveInfobar` function. r=pbro, a=lizzard
☠☠ backed out by 4c6f403862d3 ☠ ☠
authorMatteo Ferretti <mferretti@mozilla.com>
Tue, 28 Mar 2017 12:40:22 +0200
changeset 379374 f6bde67d0e05340f1d67c71eb7f7c6e3dd1760f7
parent 379373 7844cf131047221c0bd6475cf5c2608cb7050c29
child 379375 f32cb099303d0fda48caa1f50237ca6de0f2acc9
push id1419
push userjlund@mozilla.com
push dateMon, 10 Apr 2017 20:44:07 +0000
treeherdermozilla-release@5e6801b73ef6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspbro, lizzard
bugs1349275
milestone53.0
Bug 1349275 - Refactor `moveInfobar` function. r=pbro, a=lizzard - Added `getViewportDimensions` - Added `getComputedStylePropertyValue` to `CanvasFrameAnonymousContentHelper` - Refactored totally `moveInfobar` to works with both APZ enabled and new positioned absolutely highlighters - Updated `AutoRefreshHighlighter` for having a `scrollUpdate` method. - Updated tests MozReview-Commit-ID: 5m31ZzRzLXr
devtools/client/inspector/test/browser_inspector_highlighter-zoom.js
devtools/client/inspector/test/browser_inspector_infobar_01.js
devtools/client/inspector/test/browser_inspector_infobar_03.js
devtools/server/actors/highlighters.css
devtools/server/actors/highlighters/auto-refresh.js
devtools/server/actors/highlighters/box-model.js
devtools/server/actors/highlighters/utils/markup.js
devtools/shared/layout/utils.js
--- a/devtools/client/inspector/test/browser_inspector_highlighter-zoom.js
+++ b/devtools/client/inspector/test/browser_inspector_highlighter-zoom.js
@@ -7,18 +7,18 @@
 // Test that the highlighter stays correctly positioned and has the right aspect
 // ratio even when the page is zoomed in or out.
 
 const TEST_URL = "data:text/html;charset=utf-8,<div>zoom me</div>";
 
 // TEST_LEVELS entries should contain the zoom level to test.
 const TEST_LEVELS = [2, 1, .5];
 
-// Returns the expected style attribute value to check for on the root highlighter
-// element, for the values given.
+// Returns the expected style attribute value to check for on the highlighter's elements
+// node, for the values given.
 const expectedStyle = (w, h, z) =>
         (z !== 1 ? `transform-origin:top left; transform:scale(${1 / z}); ` : "") +
         `position:absolute; width:${w * z}px;height:${h * z}px; ` +
         "overflow:hidden";
 
 add_task(function* () {
   let {inspector, testActor} = yield openInspectorForURL(TEST_URL);
 
@@ -35,17 +35,17 @@ add_task(function* () {
     yield testActor.zoomPageTo(level);
     isVisible = yield testActor.isHighlighting();
     ok(isVisible, "The highlighter is still visible at zoom level " + level);
 
     yield testActor.isNodeCorrectlyHighlighted("div", is);
 
     info("Check that the highlighter root wrapper node was scaled down");
 
-    let style = yield getRootNodeStyle(testActor);
+    let style = yield getElementsNodeStyle(testActor);
     let { width, height } = yield testActor.getWindowDimensions();
     is(style, expectedStyle(width, height, level),
       "The style attribute of the root element is correct");
   }
 });
 
 function* hoverElement(selector, inspector) {
   info("Hovering node " + selector + " in the markup view");
@@ -55,13 +55,13 @@ function* hoverElement(selector, inspect
 
 function* hoverContainer(container, inspector) {
   let onHighlight = inspector.toolbox.once("node-highlight");
   EventUtils.synthesizeMouse(container.tagLine, 2, 2, {type: "mousemove"},
       inspector.markup.doc.defaultView);
   yield onHighlight;
 }
 
-function* getRootNodeStyle(testActor) {
+function* getElementsNodeStyle(testActor) {
   let value = yield testActor.getHighlighterNodeAttribute(
-    "box-model-root", "style");
+    "box-model-elements", "style");
   return value;
 }
--- a/devtools/client/inspector/test/browser_inspector_infobar_01.js
+++ b/devtools/client/inspector/test/browser_inspector_infobar_01.js
@@ -13,47 +13,52 @@ add_task(function* () {
 
   let testData = [
     {
       selector: "#top",
       position: "bottom",
       tag: "div",
       id: "top",
       classes: ".class1.class2",
-      dims: "500" + " \u00D7 " + "100"
+      dims: "500" + " \u00D7 " + "100",
+      arrowed: true
     },
     {
       selector: "#vertical",
-      position: "overlap",
+      position: "top",
       tag: "div",
       id: "vertical",
-      classes: ""
+      classes: "",
+      arrowed: false
       // No dims as they will vary between computers
     },
     {
       selector: "#bottom",
       position: "top",
       tag: "div",
       id: "bottom",
       classes: "",
-      dims: "500" + " \u00D7 " + "100"
+      dims: "500" + " \u00D7 " + "100",
+      arrowed: true
     },
     {
       selector: "body",
       position: "bottom",
       tag: "body",
-      classes: ""
+      classes: "",
+      arrowed: true
       // No dims as they will vary between computers
     },
     {
       selector: "clipPath",
       position: "bottom",
       tag: "clipPath",
       id: "clip",
-      classes: ""
+      classes: "",
+      arrowed: false
       // No dims as element is not displayed and we just want to test tag name
     },
   ];
 
   for (let currTest of testData) {
     yield testPosition(currTest, inspector, testActor);
   }
 });
@@ -76,14 +81,19 @@ function* testPosition(test, inspector, 
       "box-model-infobar-id");
     is(id, "#" + test.id, "node " + test.selector + ": id matches.");
   }
 
   let classes = yield testActor.getHighlighterNodeTextContent(
     "box-model-infobar-classes");
   is(classes, test.classes, "node " + test.selector + ": classes match.");
 
+  let arrowed = !(yield testActor.getHighlighterNodeAttribute(
+    "box-model-infobar-container", "hide-arrow"));
+
+  is(arrowed, test.arrowed, "node " + test.selector + ": arrow visibility match.");
+
   if (test.dims) {
     let dims = yield testActor.getHighlighterNodeTextContent(
       "box-model-infobar-dimensions");
     is(dims, test.dims, "node " + test.selector + ": dims match.");
   }
 }
--- a/devtools/client/inspector/test/browser_inspector_infobar_03.js
+++ b/devtools/client/inspector/test/browser_inspector_infobar_03.js
@@ -9,33 +9,33 @@
 const TEST_URI = URL_ROOT + "doc_inspector_infobar_03.html";
 
 add_task(function* () {
   let {inspector, testActor} = yield openInspectorForURL(TEST_URI);
 
   let testData = {
     selector: "body",
     position: "overlap",
-    style: "top:0px",
+    style: "position:fixed",
   };
 
   yield testPositionAndStyle(testData, inspector, testActor);
 });
 
 function* testPositionAndStyle(test, inspector, testActor) {
   info("Testing " + test.selector);
 
   yield selectAndHighlightNode(test.selector, inspector);
 
   let style = yield testActor.getHighlighterNodeAttribute(
     "box-model-infobar-container", "style");
 
-  is(style.split(";")[0], test.style,
+  is(style.split(";")[0].trim(), test.style,
     "Infobar shows on top of the page when page isn't scrolled");
 
   yield testActor.scrollWindow(0, 500);
 
   style = yield testActor.getHighlighterNodeAttribute(
     "box-model-infobar-container", "style");
 
-  is(style.split(";")[0], test.style,
+  is(style.split(";")[0].trim(), test.style,
     "Infobar shows on top of the page even if the page is scrolled");
 }
--- a/devtools/server/actors/highlighters.css
+++ b/devtools/server/actors/highlighters.css
@@ -28,16 +28,17 @@
 }
 
 :-moz-native-anonymous .highlighter-container {
   --highlighter-guide-color: #08c;
   --highlighter-content-color: #87ceeb;
   --highlighter-bubble-text-color: hsl(216, 33%, 97%);
   --highlighter-bubble-background-color: hsl(214, 13%, 24%);
   --highlighter-bubble-border-color: rgba(255, 255, 255, 0.2);
+  --highlighter-bubble-arrow-size: 8px;
 }
 
 /**
  * Highlighters are asbolute positioned in the page by default.
  * A single highlighter can have fixed position in its css class if needed (see below the
  * eye dropper or rulers highlighter, for example); but if it has to handle the
  * document's scrolling (as rulers does), it would lag a bit behind due the APZ (Async
  * Pan/Zoom module), that performs asynchronously panning and zooming on the compositor
@@ -136,25 +137,21 @@
   background: var(--highlighter-bubble-background-color) no-repeat padding-box;
 
   color: var(--highlighter-bubble-text-color);
   text-shadow: none;
 
   border: 1px solid var(--highlighter-bubble-border-color);
 }
 
-:-moz-native-anonymous [class$=infobar-container][hide-arrow] > [class$=infobar] {
-  margin: 7px 0;
-}
-
 /* Arrows */
 
 :-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:before {
-  left: calc(50% - 8px);
-  border: 8px solid var(--highlighter-bubble-border-color);
+  left: calc(50% - var(--highlighter-bubble-arrow-size));
+  border: var(--highlighter-bubble-arrow-size) solid var(--highlighter-bubble-border-color);
 }
 
 :-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:after {
   left: calc(50% - 7px);
   border: 7px solid var(--highlighter-bubble-background-color);
 }
 
 :-moz-native-anonymous [class$=infobar-container] > [class$=infobar]:before,
--- a/devtools/server/actors/highlighters/auto-refresh.js
+++ b/devtools/server/actors/highlighters/auto-refresh.js
@@ -67,16 +67,17 @@ function AutoRefreshHighlighter(highligh
   EventEmitter.decorate(this);
 
   this.highlighterEnv = highlighterEnv;
 
   this.currentNode = null;
   this.currentQuads = {};
 
   this._winDimensions = getWindowDimensions(this.win);
+  this._scroll = { x: this.win.pageXOffset, y: this.win.pageYOffset };
 
   this.update = this.update.bind(this);
 }
 
 AutoRefreshHighlighter.prototype = {
   /**
    * Window corresponding to the current highlighterEnv
    */
@@ -188,16 +189,31 @@ AutoRefreshHighlighter.prototype = {
   _hasMoved: function () {
     let oldQuads = this.currentQuads;
     this._updateAdjustedQuads();
 
     return areQuadsDifferent(oldQuads, this.currentQuads, getCurrentZoom(this.win));
   },
 
   /**
+   * Update the knowledge we have of the current window's scrolling offset, both
+   * horizontal and vertical, and return `true` if they have changed since.
+   * @return {Boolean}
+   */
+  _hasWindowScrolled: function () {
+    let { pageXOffset, pageYOffset } = this.win;
+    let hasChanged = this._scroll.x !== pageXOffset ||
+                     this._scroll.y !== pageYOffset;
+
+    this._scroll = { x: pageXOffset, y: pageYOffset };
+
+    return hasChanged;
+  },
+
+  /**
    * Update the knowledge we have of the current window's dimensions and return `true`
    * if they have changed since.
    * @return {Boolean}
    */
   _haveWindowDimensionsChanged: function () {
     let { width, height } = getWindowDimensions(this.win);
     let haveChanged = (this._winDimensions.width !== width ||
                       this._winDimensions.height !== height);
@@ -207,16 +223,22 @@ AutoRefreshHighlighter.prototype = {
   },
 
   /**
    * Update the highlighter if the node has moved since the last update.
    */
   update: function () {
     if (!this._isNodeValid(this.currentNode) ||
        (!this._hasMoved() && !this._haveWindowDimensionsChanged())) {
+      // At this point we're not calling the `_update` method. However, if the window has
+      // scrolled, we want to invoke `_scrollUpdate`.
+      if (this._hasWindowScrolled()) {
+        this._scrollUpdate();
+      }
+
       return;
     }
 
     this._update();
     this.emit("updated");
   },
 
   _show: function () {
@@ -225,20 +247,27 @@ AutoRefreshHighlighter.prototype = {
     // this.currentNode, potentially using options in this.options
     throw new Error("Custom highlighter class had to implement _show method");
   },
 
   _update: function () {
     // To be implemented by sub classes
     // When called, sub classes should update the highlighter shown for
     // this.currentNode
-    // This is called as a result of a page scroll, zoom or repaint
+    // This is called as a result of a page zoom or repaint
     throw new Error("Custom highlighter class had to implement _update method");
   },
 
+  _scrollUpdate: function () {
+    // Can be implemented by sub classes
+    // When called, sub classes can upate the highlighter shown for
+    // this.currentNode
+    // This is called as a result of a page scroll
+  },
+
   _hide: function () {
     // To be implemented by sub classes
     // When called, sub classes should actually hide the highlighter
     throw new Error("Custom highlighter class had to implement _hide method");
   },
 
   _startRefreshLoop: function () {
     let win = this.currentNode.ownerDocument.defaultView;
--- a/devtools/server/actors/highlighters/box-model.js
+++ b/devtools/server/actors/highlighters/box-model.js
@@ -334,16 +334,20 @@ BoxModelHighlighter.prototype = extend(A
       this._hide();
     }
 
     setIgnoreLayoutChanges(false, this.highlighterEnv.window.document.documentElement);
 
     return shown;
   },
 
+  _scrollUpdate: function () {
+    this._moveInfobar();
+  },
+
   /**
    * Hide the highlighter, the outline and the infobar.
    */
   _hide: function () {
     setIgnoreLayoutChanges(true);
 
     this._untrackMutations();
     this._hideBoxModel();
@@ -490,17 +494,17 @@ BoxModelHighlighter.prototype = extend(A
       if (boxType === options.region && !options.hideGuides) {
         this._showGuides(boxType);
       } else if (options.hideGuides) {
         this._hideGuides();
       }
     }
 
     // Un-zoom the root wrapper if the page was zoomed.
-    let rootId = this.ID_CLASS_PREFIX + "root";
+    let rootId = this.ID_CLASS_PREFIX + "elements";
     this.markup.scaleRootElement(this.currentNode, rootId);
 
     return true;
   },
 
   _getBoxPathCoordinates: function (boxQuad, nextBoxQuad) {
     let {p1, p2, p3, p4} = boxQuad;
 
--- a/devtools/server/actors/highlighters/utils/markup.js
+++ b/devtools/server/actors/highlighters/utils/markup.js
@@ -1,16 +1,16 @@
 /* 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 { Cc, Ci, Cu } = require("chrome");
-const { getCurrentZoom, getWindowDimensions,
+const { getCurrentZoom, getWindowDimensions, getViewportDimensions,
   getRootBindingParent } = require("devtools/shared/layout/utils");
 const { on, emit } = require("sdk/event/core");
 
 const lazyContainer = {};
 
 loader.lazyRequireGetter(lazyContainer, "CssLogic",
   "devtools/server/css-logic", true);
 exports.getComputedStyle = (node) =>
@@ -33,20 +33,16 @@ exports.removePseudoClassLock = (...args
 exports.getCSSStyleRules = (...args) =>
   lazyContainer.DOMUtils.getCSSStyleRules(...args);
 
 const SVG_NS = "http://www.w3.org/2000/svg";
 const XHTML_NS = "http://www.w3.org/1999/xhtml";
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 const STYLESHEET_URI = "resource://devtools/server/actors/" +
                        "highlighters.css";
-// How high is the infobar (px).
-const INFOBAR_HEIGHT = 34;
-// What's the size of the infobar arrow (px).
-const INFOBAR_ARROW_SIZE = 9;
 
 const _tokens = Symbol("classList/tokens");
 
 /**
  * Shims the element's `classList` for anonymous content elements; used
  * internally by `CanvasFrameAnonymousContentHelper.getElement()` method.
  */
 function ClassList(className) {
@@ -244,30 +240,32 @@ function CanvasFrameAnonymousContentHelp
   if (doc.documentElement && doc.readyState != "uninitialized") {
     this._insert();
   }
 
   this._onWindowReady = this._onWindowReady.bind(this);
   this.highlighterEnv.on("window-ready", this._onWindowReady);
 
   this.listeners = new Map();
+  this.elements = new Map();
 }
 
 CanvasFrameAnonymousContentHelper.prototype = {
-  destroy: function () {
+  destroy() {
     this._remove();
     this.highlighterEnv.off("window-ready", this._onWindowReady);
     this.highlighterEnv = this.nodeBuilder = this._content = null;
     this.anonymousContentDocument = null;
     this.anonymousContentGlobal = null;
 
     this._removeAllListeners();
+    this.elements.clear();
   },
 
-  _insert: function () {
+  _insert() {
     let doc = this.highlighterEnv.document;
     // Wait for DOMContentLoaded before injecting the anonymous content.
     if (doc.readyState != "interactive" && doc.readyState != "complete") {
       doc.addEventListener("DOMContentLoaded", this._insert.bind(this),
                            { once: true });
       return;
     }
     // Reject XUL documents. Check that after DOMContentLoaded as we query
@@ -312,63 +310,62 @@ CanvasFrameAnonymousContentHelper.protot
 
   /**
    * The "window-ready" event can be triggered when:
    *   - a new window is created
    *   - a window is unfrozen from bfcache
    *   - when first attaching to a page
    *   - when swapping frame loaders (moving tabs, toggling RDM)
    */
-  _onWindowReady: function (e, {isTopLevel}) {
+  _onWindowReady(e, {isTopLevel}) {
     if (isTopLevel) {
       this._remove();
       this._removeAllListeners();
+      this.elements.clear();
       this._insert();
       this.anonymousContentDocument = this.highlighterEnv.document;
     }
   },
 
-  getTextContentForElement: function (id) {
-    if (!this.content) {
-      return null;
-    }
-    return this.content.getTextContentForElement(id);
+  getComputedStylePropertyValue(id, property) {
+    return this.content && this.content.getComputedStylePropertyValue(id, property);
   },
 
-  setTextContentForElement: function (id, text) {
+  getTextContentForElement(id) {
+    return this.content && this.content.getTextContentForElement(id);
+  },
+
+  setTextContentForElement(id, text) {
     if (this.content) {
       this.content.setTextContentForElement(id, text);
     }
   },
 
-  setAttributeForElement: function (id, name, value) {
+  setAttributeForElement(id, name, value) {
     if (this.content) {
       this.content.setAttributeForElement(id, name, value);
     }
   },
 
-  getAttributeForElement: function (id, name) {
-    if (!this.content) {
-      return null;
-    }
-    return this.content.getAttributeForElement(id, name);
+  getAttributeForElement(id, name) {
+    return this.content && this.content.getAttributeForElement(id, name);
   },
 
-  removeAttributeForElement: function (id, name) {
+  removeAttributeForElement(id, name) {
     if (this.content) {
       this.content.removeAttributeForElement(id, name);
     }
   },
 
-  hasAttributeForElement: function (id, name) {
+  hasAttributeForElement(id, name) {
     return typeof this.getAttributeForElement(id, name) === "string";
   },
 
-  getCanvasContext: function (id, type = "2d") {
-    return this.content ? this.content.getCanvasContext(id, type) : null;
+  getCanvasContext(id, type = "2d") {
+    return this.content && this.content.getCanvasContext(id, type);
   },
 
   /**
    * Add an event listener to one of the elements inserted in the canvasFrame
    * native anonymous container.
    * Like other methods in this helper, this requires the ID of the element to
    * be passed in.
    *
@@ -397,17 +394,17 @@ CanvasFrameAnonymousContentHelper.protot
    * browser level and if the event originalTarget is found to have the provided
    * ID, the callback is executed (and then IDs of parent nodes of the
    * originalTarget are checked too).
    *
    * @param {String} id
    * @param {String} type
    * @param {Function} handler
    */
-  addEventListenerForElement: function (id, type, handler) {
+  addEventListenerForElement(id, type, handler) {
     if (typeof id !== "string") {
       throw new Error("Expected a string ID in addEventListenerForElement but" +
         " got: " + id);
     }
 
     // If no one is listening for this type of event yet, add one listener.
     if (!this.listeners.has(type)) {
       let target = this.highlighterEnv.pageListenerTarget;
@@ -421,31 +418,31 @@ CanvasFrameAnonymousContentHelper.protot
   },
 
   /**
    * Remove an event listener from one of the elements inserted in the
    * canvasFrame native anonymous container.
    * @param {String} id
    * @param {String} type
    */
-  removeEventListenerForElement: function (id, type) {
+  removeEventListenerForElement(id, type) {
     let listeners = this.listeners.get(type);
     if (!listeners) {
       return;
     }
     listeners.delete(id);
 
     // If no one is listening for event type anymore, remove the listener.
     if (!this.listeners.has(type)) {
       let target = this.highlighterEnv.pageListenerTarget;
       target.removeEventListener(type, this, true);
     }
   },
 
-  handleEvent: function (event) {
+  handleEvent(event) {
     let listeners = this.listeners.get(event.type);
     if (!listeners) {
       return;
     }
 
     // Hide the originalTarget property to avoid exposing references to native
     // anonymous elements. See addEventListenerForElement's comment.
     let isPropagationStopped = false;
@@ -472,49 +469,60 @@ CanvasFrameAnonymousContentHelper.protot
         if (isPropagationStopped) {
           break;
         }
       }
       node = node.parentNode;
     }
   },
 
-  _removeAllListeners: function () {
+  _removeAllListeners() {
     if (this.highlighterEnv) {
       let target = this.highlighterEnv.pageListenerTarget;
       for (let [type] of this.listeners) {
         target.removeEventListener(type, this, true);
       }
     }
     this.listeners.clear();
   },
 
-  getElement: function (id) {
+  getElement(id) {
+    if (this.elements.has(id)) {
+      return this.elements.get(id);
+    }
+
     let classList = new ClassList(this.getAttributeForElement(id, "class"));
 
     on(classList, "update", () => {
       this.setAttributeForElement(id, "class", classList.toString());
     });
 
-    return {
+    let element = {
       getTextContent: () => this.getTextContentForElement(id),
       setTextContent: text => this.setTextContentForElement(id, text),
       setAttribute: (name, val) => this.setAttributeForElement(id, name, val),
       getAttribute: name => this.getAttributeForElement(id, name),
       removeAttribute: name => this.removeAttributeForElement(id, name),
       hasAttribute: name => this.hasAttributeForElement(id, name),
       getCanvasContext: type => this.getCanvasContext(id, type),
       addEventListener: (type, handler) => {
         return this.addEventListenerForElement(id, type, handler);
       },
       removeEventListener: (type, handler) => {
         return this.removeEventListenerForElement(id, type, handler);
       },
+      computedStyle: {
+        getPropertyValue: property => this.getComputedStylePropertyValue(id, property)
+      },
       classList
     };
+
+    this.elements.set(id, element);
+
+    return element;
   },
 
   get content() {
     if (!this._content || Cu.isDeadWrapper(this._content)) {
       return null;
     }
     return this._content;
   },
@@ -535,17 +543,17 @@ CanvasFrameAnonymousContentHelper.protot
    *
    * Note that if the matching element already has an inline style attribute, it
    * *won't* be preserved.
    *
    * @param {DOMNode} node This node is used to determine which container window
    * should be used to read the current zoom value.
    * @param {String} id The ID of the root element inserted with this API.
    */
-  scaleRootElement: function (node, id) {
+  scaleRootElement(node, id) {
     let boundaryWindow = this.highlighterEnv.window;
     let zoom = getCurrentZoom(node);
     // Hide the root element and force the reflow in order to get the proper window's
     // dimensions without increasing them.
     this.setAttributeForElement(id, "style", "display: none");
     node.offsetWidth;
 
     let { width, height } = getWindowDimensions(boundaryWindow);
@@ -577,60 +585,96 @@ exports.CanvasFrameAnonymousContentHelpe
  * @param  {DOMNode} container
  *         The container element which will be used to position the infobar.
  * @param  {Object} bounds
  *         The content bounds of the container element.
  * @param  {Window} win
  *         The window object.
  */
 function moveInfobar(container, bounds, win) {
-  let winHeight = win.innerHeight * getCurrentZoom(win);
-  let winWidth = win.innerWidth * getCurrentZoom(win);
-  let winScrollY = win.scrollY;
+  let zoom = getCurrentZoom(win);
+  let viewport = getViewportDimensions(win);
+
+  let { computedStyle } = container;
 
-  // Ensure that containerBottom and containerTop are at least zero to avoid
-  // showing tooltips outside the viewport.
-  let containerBottom = Math.max(0, bounds.bottom) + INFOBAR_ARROW_SIZE;
-  let containerTop = Math.min(winHeight, bounds.top);
+  // To simplify, we use the same arrow's size value as margin's value for all four sides.
+  let margin = parseFloat(computedStyle
+                              .getPropertyValue("--highlighter-bubble-arrow-size"));
+  let containerHeight = parseFloat(computedStyle.getPropertyValue("height"));
+  let containerWidth = parseFloat(computedStyle.getPropertyValue("width"));
+  let containerHalfWidth = containerWidth / 2;
+
+  let viewportWidth = viewport.width * zoom;
+  let viewportHeight = viewport.height * zoom;
+  let { pageXOffset, pageYOffset } = win;
+
+  pageYOffset *= zoom;
+  pageXOffset *= zoom;
+  containerHeight += margin;
 
-  // Can the bar be above the node?
-  let top;
-  if (containerTop < INFOBAR_HEIGHT) {
-    // No. Can we move the bar under the node?
-    if (containerBottom + INFOBAR_HEIGHT > winHeight) {
-      // No. Let's move it inside. Can we show it at the top of the element?
-      if (containerTop < winScrollY) {
-        // No. Window is scrolled past the top of the element.
-        top = 0;
-      } else {
-        // Yes. Show it at the top of the element
-        top = containerTop;
-      }
-      container.setAttribute("position", "overlap");
-    } else {
-      // Yes. Let's move it under the node.
-      top = containerBottom;
-      container.setAttribute("position", "bottom");
-    }
-  } else {
-    // Yes. Let's move it on top of the node.
-    top = containerTop - INFOBAR_HEIGHT;
-    container.setAttribute("position", "top");
+  // Defines the boundaries for the infobar.
+  let topBoundary = margin;
+  let bottomBoundary = viewportHeight - containerHeight;
+  let leftBoundary = containerHalfWidth + margin;
+  let rightBoundary = viewportWidth - containerHalfWidth - margin;
+
+  // Set the default values.
+  let top = bounds.y - containerHeight;
+  let bottom = bounds.bottom + margin;
+  let left = bounds.x + bounds.width / 2;
+  let isOverlapTheNode = false;
+  let positionAttribute = "top";
+  let position = "absolute";
+
+  // Here we start the math.
+  // We basically want to position absolutely the infobar, except when is pointing to a
+  // node that is offscreen or partially offscreen, in a way that the infobar can't
+  // be placed neither on top nor on bottom.
+  // In such cases, the infobar will overlap the node, and to limit the latency given
+  // by APZ (See Bug 1312103) it will be positioned as "fixed".
+  // It's a sort of "position: sticky" (but positioned as absolute instead of relative).
+  let canBePlacedOnTop = top >= pageYOffset;
+  let canBePlacedOnBottom = bottomBoundary + pageYOffset - bottom > 0;
+
+  if (!canBePlacedOnTop && canBePlacedOnBottom) {
+    top = bottom;
+    positionAttribute = "bottom";
   }
 
-  // Align the bar with the box's center if possible.
-  let left = bounds.right - bounds.width / 2;
-  // Make sure the while infobar is visible.
-  let buffer = 100;
-  if (left < buffer) {
-    left = buffer;
-    container.setAttribute("hide-arrow", "true");
-  } else if (left > winWidth - buffer) {
-    left = winWidth - buffer;
+  let isOffscreenOnTop = top < topBoundary + pageYOffset;
+  let isOffscreenOnBottom = top > bottomBoundary + pageYOffset;
+  let isOffscreenOnLeft = left < leftBoundary + pageXOffset;
+  let isOffscreenOnRight = left > rightBoundary + pageXOffset;
+
+  if (isOffscreenOnTop) {
+    top = topBoundary;
+    isOverlapTheNode = true;
+  } else if (isOffscreenOnBottom) {
+    top = bottomBoundary;
+    isOverlapTheNode = true;
+  } else if (isOffscreenOnLeft || isOffscreenOnRight) {
+    isOverlapTheNode = true;
+    top -= pageYOffset;
+  }
+
+  if (isOverlapTheNode) {
+    left = Math.min(Math.max(leftBoundary, left - pageXOffset), rightBoundary);
+
+    position = "fixed";
     container.setAttribute("hide-arrow", "true");
   } else {
+    position = "absolute";
     container.removeAttribute("hide-arrow");
   }
 
-  let style = "top:" + top + "px;left:" + left + "px;";
-  container.setAttribute("style", style);
+  // We need to scale the infobar Independently from the highlighter's container;
+  // otherwise the `position: fixed` won't work, since "any value other than `none` for
+  // the transform, results in the creation of both a stacking context and a containing
+  // block. The object acts as a containing block for fixed positioned descendants."
+  // (See https://www.w3.org/TR/css-transforms-1/#transform-rendering)
+  container.setAttribute("style", `
+    position:${position};
+    transform-origin: 0 0;
+    transform: scale(${1 / zoom}) translate(${left}px, ${top}px)`);
+
+  container.setAttribute("position", positionAttribute);
 }
 exports.moveInfobar = moveInfobar;
--- a/devtools/shared/layout/utils.js
+++ b/devtools/shared/layout/utils.js
@@ -655,16 +655,36 @@ function getWindowDimensions(window) {
     height -= scrollbarHeight.value;
   }
 
   return { width, height };
 }
 exports.getWindowDimensions = getWindowDimensions;
 
 /**
+ * Returns the viewport's dimensions for the `window` given.
+ *
+ * @return {Object} An object with `width` and `height` properties, representing the
+ * number of pixels for the viewport's size.
+ */
+function getViewportDimensions(window) {
+  let windowUtils = utilsFor(window);
+
+  let scrollbarHeight = {};
+  let scrollbarWidth = {};
+  windowUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
+
+  let width = window.innerWidth - scrollbarWidth.value;
+  let height = window.innerHeight - scrollbarHeight.value;
+
+  return { width, height };
+}
+exports.getViewportDimensions = getViewportDimensions;
+
+/**
  * Returns the max size allowed for a surface like textures or canvas.
  * If no `webgl` context is available, DEFAULT_MAX_SURFACE_SIZE is returned instead.
  *
  * @param {DOMNode|DOMWindow|DOMDocument} node The node to get the window for.
  * @return {Number} the max size allowed
  */
 const DEFAULT_MAX_SURFACE_SIZE = 4096;
 function getMaxSurfaceSize(node) {