Bug 1275399 - Change inspector breadcrumbs to add RTL functionality. r=jdescottes
authorSteve Melia <steve.j.melia@gmail.com>
Mon, 06 Jun 2016 01:25:39 +0100
changeset 309239 28ded847d319a75a1ea5e651447dffa2103914b2
parent 309238 62dd82f4b4c09709fbe7a6ce3345fe35c1755ba2
child 309240 eb1e3117f53c7c2a03d342a22db0e3f2f63c2fe7
push id20303
push userryanvm@gmail.com
push dateMon, 15 Aug 2016 18:14:31 +0000
treeherderfx-team@590559290122 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjdescottes
bugs1275399
milestone51.0a1
Bug 1275399 - Change inspector breadcrumbs to add RTL functionality. r=jdescottes
devtools/client/inspector/breadcrumbs.js
devtools/client/inspector/test/browser.ini
devtools/client/inspector/test/browser_inspector_breadcrumbs_visibility.js
devtools/client/inspector/test/doc_inspector_breadcrumbs_visibility.html
--- a/devtools/client/inspector/breadcrumbs.js
+++ b/devtools/client/inspector/breadcrumbs.js
@@ -18,16 +18,19 @@ const ELLIPSIS = Services.prefs.getCompl
 const MAX_LABEL_LENGTH = 40;
 
 const NS_XHTML = "http://www.w3.org/1999/xhtml";
 const SCROLL_REPEAT_MS = 100;
 
 const EventEmitter = require("devtools/shared/event-emitter");
 const {KeyShortcuts} = require("devtools/client/shared/key-shortcuts");
 
+// Some margin may be required for visible element detection.
+const SCROLL_MARGIN = 1;
+
 /**
  * Component to replicate functionality of XUL arrowscrollbox
  * for breadcrumbs
  *
  * @param {Window} win The window containing the breadcrumbs
  * @parem {DOMNode} container The element in which to put the scroll box
  */
 function ArrowScrollBox(win, container) {
@@ -35,16 +38,19 @@ function ArrowScrollBox(win, container) 
   this.doc = win.document;
   this.container = container;
   EventEmitter.decorate(this);
   this.init();
 }
 
 ArrowScrollBox.prototype = {
 
+  // Scroll behavior, exposed for testing
+  scrollBehavior: "smooth",
+
   /**
    * Build the HTML, add to the DOM and start listening to
    * events
    */
   init: function () {
     this.constructHtml();
 
     this.onUnderflow();
@@ -64,19 +70,35 @@ ArrowScrollBox.prototype = {
     this.endBtn.addEventListener("dblclick", this.onEndBtnDblClick, false);
 
     // Overflow and underflow are moz specific events
     this.inner.addEventListener("underflow", this.onUnderflow, false);
     this.inner.addEventListener("overflow", this.onOverflow, false);
   },
 
   /**
+   * Determine whether the current text directionality is RTL
+   */
+  isRtl: function () {
+    return this.win.getComputedStyle(this.container).direction === "rtl";
+  },
+
+  /**
+   * Scroll to the specified element using the current scroll behavior
+   * @param {Element} element element to scroll
+   * @param {String} block desired alignment of element after scrolling
+   */
+  scrollToElement: function (element, block) {
+    element.scrollIntoView({ block: block, behavior: this.scrollBehavior });
+  },
+
+  /**
    * Call the given function once; then continuously
    * while the mouse button is held
-   * @param {repeatFn} the function to repeat while the button is held
+   * @param {Function} repeatFn the function to repeat while the button is held
    */
   clickOrHold: function (repeatFn) {
     let timer;
     let container = this.container;
 
     function handleClick() {
       cancelHold();
       repeatFn();
@@ -104,59 +126,61 @@ ArrowScrollBox.prototype = {
    */
   onStartBtnDblClick: function () {
     let children = this.inner.childNodes;
     if (children.length < 1) {
       return;
     }
 
     let element = this.inner.childNodes[0];
-    element.scrollIntoView({ block: "start", behavior: "smooth" });
+    this.scrollToElement(element, "start");
   },
 
   /**
    * When end button is dbl clicked scroll to last element
    */
   onEndBtnDblClick: function () {
     let children = this.inner.childNodes;
     if (children.length < 1) {
       return;
     }
 
     let element = children[children.length - 1];
-    element.scrollIntoView({ block: "start", behavior: "smooth" });
+    this.scrollToElement(element, "start");
   },
 
   /**
    * When start arrow button is clicked scroll towards first element
    */
   onStartBtnClick: function () {
     let scrollToStart = () => {
       let element = this.getFirstInvisibleElement();
       if (!element) {
         return;
       }
 
-      element.scrollIntoView({ block: "start", behavior: "smooth" });
+      let block = this.isRtl() ? "end" : "start";
+      this.scrollToElement(element, block);
     };
 
     this.clickOrHold(scrollToStart);
   },
 
   /**
    * When end arrow button is clicked scroll towards last element
    */
   onEndBtnClick: function () {
     let scrollToEnd = () => {
       let element = this.getLastInvisibleElement();
       if (!element) {
         return;
       }
 
-      element.scrollIntoView({ block: "end", behavior: "smooth" });
+      let block = this.isRtl() ? "start" : "end";
+      this.scrollToElement(element, block);
     };
 
     this.clickOrHold(scrollToEnd);
   },
 
   /**
    * Event handler for scrolling, update the
    * enabled/disabled status of the arrow buttons
@@ -191,56 +215,82 @@ ArrowScrollBox.prototype = {
    */
   onOverflow: function () {
     this.startBtn.style.visibility = "visible";
     this.endBtn.style.visibility = "visible";
     this.emit("overflow");
   },
 
   /**
+   * Check whether the element is to the left of its container but does
+   * not also span the entire container.
+   * @param {Number} left the left scroll point of the container
+   * @param {Number} right the right edge of the container
+   * @param {Number} elementLeft the left edge of the element
+   * @param {Number} elementRight the right edge of the element
+   */
+  elementLeftOfContainer: function (left, right, elementLeft, elementRight) {
+    return elementLeft < (left - SCROLL_MARGIN)
+           && elementRight < (right - SCROLL_MARGIN);
+  },
+
+  /**
+   * Check whether the element is to the right of its container but does
+   * not also span the entire container.
+   * @param {Number} left the left scroll point of the container
+   * @param {Number} right the right edge of the container
+   * @param {Number} elementLeft the left edge of the element
+   * @param {Number} elementRight the right edge of the element
+   */
+  elementRightOfContainer: function (left, right, elementLeft, elementRight) {
+    return elementLeft > (left + SCROLL_MARGIN)
+           && elementRight > (right + SCROLL_MARGIN);
+  },
+
+  /**
    * Get the first (i.e. furthest left for LTR)
-   * non visible element in the scroll box
+   * non or partly visible element in the scroll box
    */
   getFirstInvisibleElement: function () {
-    let start = this.inner.scrollLeft;
-    let end = this.inner.scrollLeft + this.inner.clientWidth;
-    let crumbs = this.inner.childNodes;
-    for (let i = crumbs.length - 1; i > -1; i--) {
-      let element = crumbs[i];
-      let elementRight = element.offsetLeft + element.offsetWidth;
-      if (element.offsetLeft < start) {
-        // edge case, check the element isn't already visible
-        if (elementRight >= end) {
-          continue;
-        }
-        return element;
-      }
-    }
+    let elementsList = Array.from(this.inner.childNodes).reverse();
 
-    return null;
+    let predicate = this.isRtl() ?
+      this.elementRightOfContainer : this.elementLeftOfContainer;
+    return this.findFirstWithBounds(elementsList, predicate);
   },
 
   /**
    * Get the last (i.e. furthest right for LTR)
-   * non-visible element in the scroll box
+   * non or partly visible element in the scroll box
    */
   getLastInvisibleElement: function () {
-    let end = this.inner.scrollLeft + this.inner.clientWidth;
-    let elementStart = 0;
-    for (let element of this.inner.childNodes) {
-      let elementEnd = elementStart + element.offsetWidth;
-      if (elementEnd > end) {
-        // Edge case: check the element isn't bigger than the
-        // container and thus already in view
-        if (elementStart > this.inner.scrollLeft) {
-          return element;
-        }
+    let predicate = this.isRtl() ?
+      this.elementLeftOfContainer : this.elementRightOfContainer;
+    return this.findFirstWithBounds(this.inner.childNodes, predicate);
+  },
+
+  /**
+   * Find the first element that matches the given predicate, called with bounds
+   * information
+   * @param {Array} elements an ordered list of elements
+   * @param {Function} predicate a function to be called with bounds
+   * information
+   */
+  findFirstWithBounds: function (elements, predicate) {
+    let left = this.inner.scrollLeft;
+    let right = left + this.inner.clientWidth;
+    for (let element of elements) {
+      let elementLeft = element.offsetLeft - element.parentElement.offsetLeft;
+      let elementRight = elementLeft + element.offsetWidth;
+
+      // Check that the starting edge of the element is out of the visible area
+      // and that the ending edge does not span the whole container
+      if (predicate(left, right, elementLeft, elementRight)) {
+        return element;
       }
-
-      elementStart = elementEnd;
     }
 
     return null;
   },
 
   /**
    * Build the HTML for the scroll box and insert it into the DOM
    */
@@ -720,17 +770,17 @@ HTMLBreadcrumbs.prototype = {
 
   /**
    * Ensure the selected node is visible.
    */
   scroll: function () {
     // FIXME bug 684352: make sure its immediate neighbors are visible too.
     if (!this.isDestroyed) {
       let element = this.nodeHierarchy[this.currentIndex].button;
-      element.scrollIntoView({ block: "end", behavior: "smooth" });
+      this.arrowScrollBox.scrollToElement(element, "end");
     }
   },
 
   /**
    * Update all button outputs.
    */
   updateSelectors: function () {
     if (this.isDestroyed) {
--- a/devtools/client/inspector/test/browser.ini
+++ b/devtools/client/inspector/test/browser.ini
@@ -1,14 +1,15 @@
 [DEFAULT]
 tags = devtools
 subsuite = devtools
 support-files =
   doc_inspector_add_node.html
   doc_inspector_breadcrumbs.html
+  doc_inspector_breadcrumbs_visibility.html
   doc_inspector_delete-selected-node-01.html
   doc_inspector_delete-selected-node-02.html
   doc_inspector_embed.html
   doc_inspector_gcli-inspect-command.html
   doc_inspector_highlight_after_transition.html
   doc_inspector_highlighter-comments.html
   doc_inspector_highlighter-geometry_01.html
   doc_inspector_highlighter-geometry_02.html
@@ -43,16 +44,17 @@ support-files =
 [browser_inspector_addNode_03.js]
 [browser_inspector_breadcrumbs.js]
 [browser_inspector_breadcrumbs_highlight_hover.js]
 [browser_inspector_breadcrumbs_keybinding.js]
 [browser_inspector_breadcrumbs_keyboard_trap.js]
 skip-if = os == "mac" # Full keyboard navigation on OSX only works if Full Keyboard Access setting is set to All Control in System Keyboard Preferences
 [browser_inspector_breadcrumbs_mutations.js]
 [browser_inspector_breadcrumbs_namespaced.js]
+[browser_inspector_breadcrumbs_visibility.js]
 [browser_inspector_delete-selected-node-01.js]
 [browser_inspector_delete-selected-node-02.js]
 [browser_inspector_delete-selected-node-03.js]
 [browser_inspector_destroy-after-navigation.js]
 [browser_inspector_destroy-before-ready.js]
 [browser_inspector_expand-collapse.js]
 [browser_inspector_gcli-inspect-command.js]
 [browser_inspector_highlighter-01.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/browser_inspector_breadcrumbs_visibility.js
@@ -0,0 +1,106 @@
+/* vim: set ft=javascript 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 start and end buttons on the breadcrumb trail bring the right
+// crumbs into the visible area, for both LTR and RTL
+
+let { Toolbox } = require("devtools/client/framework/toolbox");
+
+const TEST_URI = URL_ROOT + "doc_inspector_breadcrumbs_visibility.html";
+const NODE_ONE = "div#aVeryLongIdToExceedTheBreadcrumbTruncationLimit";
+const NODE_TWO = "div#anotherVeryLongIdToExceedTheBreadcrumbTruncationLimit";
+const NODE_THREE = "div#aThirdVeryLongIdToExceedTheTruncationLimit";
+const NODE_FOUR = "div#aFourthOneToExceedTheTruncationLimit";
+const NODE_FIVE = "div#aFifthOneToExceedTheTruncationLimit";
+const NODE_SIX = "div#aSixthOneToExceedTheTruncationLimit";
+const NODE_SEVEN = "div#aSeventhOneToExceedTheTruncationLimit";
+
+const NODES = [
+  { action: "start", title: NODE_SIX },
+  { action: "start", title: NODE_FIVE },
+  { action: "start", title: NODE_FOUR },
+  { action: "start", title: NODE_THREE },
+  { action: "start", title: NODE_TWO },
+  { action: "start", title: NODE_ONE },
+  { action: "end", title: NODE_TWO },
+  { action: "end", title: NODE_THREE },
+  { action: "end", title: NODE_FOUR },
+  { action: "end", title: NODE_FIVE },
+  { action: "end", title: NODE_SIX }
+];
+
+add_task(function* () {
+  let { inspector, toolbox } = yield openInspectorForURL(TEST_URI);
+
+  // No way to wait for scrolling to end (Bug 1172171)
+  // Rather than wait a max time; limit test to instant scroll behavior
+  inspector.breadcrumbs.arrowScrollBox.scrollBehavior = "instant";
+
+  yield toolbox.switchHost(Toolbox.HostType.WINDOW);
+  let hostWindow = toolbox._host._window;
+  let originalWidth = hostWindow.outerWidth;
+  let originalHeight = hostWindow.outerHeight;
+  hostWindow.resizeTo(640, 300);
+
+  info("Testing transitions ltr");
+  yield pushPref("intl.uidirection.en-US", "ltr");
+  yield testBreadcrumbTransitions(hostWindow, inspector);
+
+  info("Testing transitions rtl");
+  yield pushPref("intl.uidirection.en-US", "rtl");
+  yield testBreadcrumbTransitions(hostWindow, inspector);
+
+  hostWindow.resizeTo(originalWidth, originalHeight);
+});
+
+function* testBreadcrumbTransitions(hostWindow, inspector) {
+  let breadcrumbs = inspector.panelDoc.getElementById("inspector-breadcrumbs");
+  let startBtn = breadcrumbs.querySelector(".scrollbutton-up");
+  let endBtn = breadcrumbs.querySelector(".scrollbutton-down");
+  let container = breadcrumbs.querySelector(".html-arrowscrollbox-inner");
+  let breadcrumbsUpdated = inspector.once("breadcrumbs-updated");
+
+  info("Selecting initial node");
+  yield selectNode(NODE_SEVEN, inspector);
+
+  // So just need to wait for a duration
+  yield breadcrumbsUpdated;
+  let initialCrumb = container.querySelector("button[checked]");
+  is(isElementInViewport(hostWindow, initialCrumb), true,
+     "initial element was visible");
+
+  for (let node of NODES) {
+    info("Checking for visibility of crumb " + node.title);
+    if (node.action === "end") {
+      info("Simulating click of end button");
+      EventUtils.synthesizeMouseAtCenter(endBtn, {}, inspector.panelWin);
+    } else if (node.action === "start") {
+      info("Simulating click of start button");
+      EventUtils.synthesizeMouseAtCenter(startBtn, {}, inspector.panelWin);
+    }
+
+    yield breadcrumbsUpdated;
+    let selector = "button[title=\"" + node.title + "\"]";
+    let relevantCrumb = container.querySelector(selector);
+    is(isElementInViewport(hostWindow, relevantCrumb), true,
+       node.title + " crumb is visible");
+  }
+}
+
+function isElementInViewport(window, el) {
+  let rect = el.getBoundingClientRect();
+
+  return (
+    rect.top >= 0 &&
+    rect.left >= 0 &&
+    rect.bottom <= window.innerHeight &&
+    rect.right <= window.innerWidth
+  );
+}
+
+registerCleanupFunction(function () {
+  // Restore the host type for other tests.
+  Services.prefs.clearUserPref("devtools.toolbox.host");
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/inspector/test/doc_inspector_breadcrumbs_visibility.html
@@ -0,0 +1,22 @@
+<html>
+    <head>
+        <meta http-equiv="content-type" content="text/html; charset=windows-1252">
+    </head>
+    <body>
+        <div id="aVeryLongIdToExceedTheBreadcrumbTruncationLimit">
+            <div id="anotherVeryLongIdToExceedTheBreadcrumbTruncationLimit">
+                <div id="aThirdVeryLongIdToExceedTheTruncationLimit">
+                    <div id="aFourthOneToExceedTheTruncationLimit">
+                        <div id="aFifthOneToExceedTheTruncationLimit">
+                            <div id="aSixthOneToExceedTheTruncationLimit">
+                                <div id="aSeventhOneToExceedTheTruncationLimit">
+                                    A text node at the end
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </body>
+</html>