Bug 1386316 - Resize Android WebExtension Options UI iframe to match its content size. draft
authorLuca Greco <lgreco@mozilla.com>
Mon, 07 Aug 2017 15:45:55 +0200
changeset 646689 7e6af4b00febc65cb0515599a26919b434a6d5e1
parent 646672 5ab5511100233277a760550ac509283278a0e3d9
child 726332 c2b3df03ac4a3ad6650e855af1df9044b4bd3e28
push id74217
push userluca.greco@alcacoop.it
push dateTue, 15 Aug 2017 18:27:37 +0000
bugs1386316
milestone57.0a1
Bug 1386316 - Resize Android WebExtension Options UI iframe to match its content size. MozReview-Commit-ID: 17a240drasZ
mobile/android/chrome/content/aboutAddons.js
mobile/android/chrome/content/aboutAddons.xhtml
mobile/android/components/extensions/test/mochitest/chrome.ini
mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html
mobile/android/themes/core/aboutAddons.css
--- a/mobile/android/chrome/content/aboutAddons.js
+++ b/mobile/android/chrome/content/aboutAddons.js
@@ -140,16 +140,20 @@ function onPopState(aEvent) {
 
 function showAddons() {
   // Hide the addon options and show the addons list
   let details = document.querySelector("#addons-details");
   details.classList.add("hidden");
   let list = document.querySelector("#addons-list");
   list.classList.remove("hidden");
   document.documentElement.removeAttribute("details");
+
+  // Clean the optionsBox content when switching to the add-ons list view.
+  let optionsBox = document.querySelector("#addons-details > .addon-item .options-box");
+  optionsBox.innerHTML = "";
 }
 
 function showAddonOptions() {
   // Hide the addon list and show the addon options
   let list = document.querySelector("#addons-list");
   list.classList.add("hidden");
   let details = document.querySelector("#addons-details");
   details.classList.remove("hidden");
@@ -354,35 +358,70 @@ var Addons = {
     let optionsURL = aListItem.getAttribute("optionsURL");
 
     // Always clean the options content before rendering the options of the
     // newly selected extension.
     optionsBox.innerHTML = "";
 
     switch (parseInt(addon.optionsType)) {
       case AddonManager.OPTIONS_TYPE_INLINE_BROWSER:
+        // Allow the options to use all the available width space.
+        optionsBox.classList.remove("inner");
+
         // WebExtensions are loaded asynchronously and the optionsURL
         // may not be available via listitem when the add-on has just been
         // installed, but it is available on the addon if one is set.
         detailItem.setAttribute("optionsURL", addon.optionsURL);
         this.createWebExtensionOptions(optionsBox, addon.optionsURL, addon.optionsBrowserStyle);
         break;
       case AddonManager.OPTIONS_TYPE_INLINE:
+        // Keep the usual layout for any options related the legacy (or system) add-ons.
+        optionsBox.classList.add("inner");
+
         this.createInlineOptions(optionsBox, optionsURL, aListItem);
         break;
     }
 
     showAddonOptions();
   },
 
   createWebExtensionOptions: async function(destination, optionsURL, browserStyle) {
+    let originalHeight;
     let frame = document.createElement("iframe");
     frame.setAttribute("id", "addon-options");
     frame.setAttribute("mozbrowser", "true");
+    frame.setAttribute("style", "width: 100%; overflow: hidden;");
+
+    // Adjust iframe height to the iframe content (also between navigation of multiple options
+    // files).
+    frame.onload = (evt) => {
+      if (evt.target !== frame) {
+        return;
+      }
+
+      const {document} = frame.contentWindow;
+      const bodyScrollHeight = document.body && document.body.scrollHeight;
+      const documentScrollHeight = document.documentElement.scrollHeight;
+
+      // Set the iframe height to the maximum between the body and the document
+      // scrollHeight values.
+      frame.style.height = Math.max(bodyScrollHeight, documentScrollHeight) + "px";
+
+      // Restore the original iframe height between option page loads,
+      // so that we don't force the new document to have the same size
+      // of the previosuly loaded option page.
+      frame.contentWindow.addEventListener("unload", () => {
+        frame.style.height = originalHeight + "px";
+      }, {once: true});
+    };
+
     destination.appendChild(frame);
+
+    originalHeight = frame.getBoundingClientRect().height;
+
     // Loading the URL this way prevents the native back
     // button from applying to the iframe.
     frame.contentWindow.location.replace(optionsURL);
   },
 
   createInlineOptions(destination, optionsURL, aListItem) {
     // This function removes and returns the text content of aNode without
     // removing any child elements. Removing the text nodes ensures any XBL
--- a/mobile/android/chrome/content/aboutAddons.xhtml
+++ b/mobile/android/chrome/content/aboutAddons.xhtml
@@ -39,19 +39,19 @@
   <div id="addons-details" class="list hidden">
     <div class="addon-item list-item">
       <img class="icon"/>
       <div class="inner">
         <div class="details">
           <div class="title"></div><div class="version"></div>
         </div>
         <div class="description-full"></div>
-        <div class="options-box"></div>
       </div>
       <div class="warn-unsigned">&addonUnsigned.message; <a id="unsigned-learn-more">&addonUnsigned.learnMore;</a></div>
+      <div class="options-box"></div>
       <div class="status status-uninstalled show-on-uninstall"></div>
       <div class="buttons">
         <button id="enable-btn" class="show-on-disable hide-on-enable hide-on-uninstall" >&addonAction.enable;</button>
         <button id="disable-btn" class="show-on-enable hide-on-disable hide-on-uninstall" >&addonAction.disable;</button>
         <button id="uninstall-btn" class="hide-on-uninstall" >&addonAction.uninstall;</button>
         <button id="cancel-btn" class="show-on-uninstall" >&addonAction.undo;</button>
       </div>
     </div>
--- a/mobile/android/components/extensions/test/mochitest/chrome.ini
+++ b/mobile/android/components/extensions/test/mochitest/chrome.ini
@@ -3,11 +3,12 @@ support-files =
   head.js
   ../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js
 tags = webextensions
 
 [test_ext_browserAction_getTitle_setTitle.html]
 [test_ext_browserAction_onClicked.html]
 [test_ext_browsingData_cookies_cache.html]
 [test_ext_browsingData_settings.html]
+[test_ext_options_ui.html]
 [test_ext_pageAction_show_hide.html]
 [test_ext_pageAction_getPopup_setPopup.html]
 skip-if = os == 'android' # bug 1373170
new file mode 100644
--- /dev/null
+++ b/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html
@@ -0,0 +1,196 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>PageAction Test</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://testing-common/ContentTask.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+async function waitAboutAddonsRendered(addonId) {
+  await ContentTaskUtils.waitForCondition(() => {
+    return content.document.querySelector(`div.addon-item[addonID="${addonId}"]`);
+  }, `wait Addon Item for ${addonId} to be rendered`);
+}
+
+async function navigateToAddonDetails(addonId) {
+  const item = content.document.querySelector(`div.addon-item[addonID="${addonId}"]`);
+  let rect = item.getBoundingClientRect();
+  const x = rect.left + rect.width / 2;
+  const y = rect.top + rect.height / 2;
+  let domWinUtils = content.window.QueryInterface(Ci.nsIInterfaceRequestor)
+                           .getInterface(Ci.nsIDOMWindowUtils);
+
+  domWinUtils.sendMouseEventToWindow("mousedown", x, y, 0, 1, 0);
+  domWinUtils.sendMouseEventToWindow("mouseup", x, y, 0, 1, 0);
+}
+
+async function waitAddonOptionsPage([addonId, expectedText]) {
+  await ContentTaskUtils.waitForCondition(() => {
+    const optionsIframe = content.document.querySelector(`#addon-options`);
+    return optionsIframe && optionsIframe.contentDocument.readyState === "complete" &&
+           optionsIframe.contentDocument.body.innerText.includes(expectedText);
+  }, `wait Addon Options ${expectedText} for ${addonId} to be loaded`);
+
+  const optionsIframe = content.document.querySelector(`#addon-options`);
+
+  return {
+    iframeHeight: optionsIframe.style.height,
+    documentHeight: optionsIframe.contentDocument.documentElement.scrollHeight,
+    bodyHeight: optionsIframe.contentDocument.body.scrollHeight,
+  };
+}
+
+async function clickOnLinkInOptionsPage(selector) {
+  const optionsIframe = content.document.querySelector(`#addon-options`);
+  optionsIframe.contentDocument.querySelector(selector).click();
+}
+
+async function navigateBack() {
+  content.window.history.back();
+}
+
+add_task(async function test_options_ui_iframe_height() {
+  let addonID = "test-options-ui@mozilla.org";
+
+  let extension = ExtensionTestUtils.loadExtension({
+    useAddonManager: "temporary",
+    manifest: {
+      applications: {
+        gecko: {id: addonID},
+      },
+      name: "Options UI Extension",
+      description: "Longer addon description",
+      options_ui: {
+        page: "options.html",
+      },
+    },
+    files: {
+      // An option page with the document element bigger than the body.
+      "options.html": `<!DOCTYPE html>
+        <html>
+          <head>
+            <meta charset="utf-8">
+            <style>
+              html { height: 500px; border: 1px solid black; }
+              body { height: 200px; }
+            </style>
+          </head>
+          <body>
+            <h1>Options page 1</h1>
+            <a href="options2.html">go to page 2</a>
+          </body>
+        </html>
+      `,
+      // A second option page with the body element bigger than the document.
+      "options2.html": `<!DOCTYPE html>
+        <html>
+          <head>
+            <meta charset="utf-8">
+            <style>
+              html { height: 200px; border: 1px solid black; }
+              body { height: 350px; }
+            </style>
+          </head>
+          <body>
+            <h1>Options page 2</h1>
+          </body>
+        </html>
+      `,
+    },
+  });
+
+  await extension.startup();
+
+  let chromeWin = Services.wm.getMostRecentWindow("navigator:browser");
+  let BrowserApp = chromeWin.BrowserApp;
+
+  let waitAboutAddonsLoaded = new Promise(resolve => {
+    let listener = (event) => {
+      if (event.target.defaultView.location.href === "about:addons") {
+        BrowserApp.deck.removeEventListener("DOMContentLoaded", listener);
+        resolve();
+      }
+    };
+
+    BrowserApp.deck.addEventListener("DOMContentLoaded", listener);
+  });
+
+  BrowserApp.selectOrAddTab("about:addons", {
+    selected: true,
+    parentId: BrowserApp.selectedTab.id,
+  });
+
+  await waitAboutAddonsLoaded;
+
+  is(BrowserApp.selectedTab.currentURI.spec, "about:addons",
+     "about:addons is the currently selected tab");
+
+  let aboutAddonsWindow = BrowserApp.selectedTab.browser.contentWindow;
+
+  is(aboutAddonsWindow.location.href, "about:addons");
+
+  await ContentTask.spawn(BrowserApp.selectedTab.browser, addonID, waitAboutAddonsRendered);
+
+  await ContentTask.spawn(BrowserApp.selectedTab.browser, addonID, navigateToAddonDetails);
+
+  const optionsSizes = await ContentTask.spawn(
+    BrowserApp.selectedTab.browser, [addonID, "Options page 1"], waitAddonOptionsPage
+  );
+
+  ok(parseInt(optionsSizes.iframeHeight, 10) >= 500,
+     "The addon options iframe is at least 500px");
+
+  is(optionsSizes.iframeHeight, optionsSizes.documentHeight + "px",
+     "The addon options iframe has the expected height");
+
+  await ContentTask.spawn(BrowserApp.selectedTab.browser, "a", clickOnLinkInOptionsPage);
+
+  const options2Sizes = await ContentTask.spawn(
+    BrowserApp.selectedTab.browser, [addonID, "Options page 2"], waitAddonOptionsPage
+  );
+
+  // The second option page has a body bigger than the document element
+  // and we expect the iframe to be bigger than that.
+  ok(parseInt(options2Sizes.iframeHeight, 10) > 200,
+     `The iframe is bigger then 200px (${options2Sizes.iframeHeight})`);
+
+  // The second option page has a body smaller than the document element of the first
+  // page and we expect the iframe to be smaller than for the previous options page.
+  ok(parseInt(options2Sizes.iframeHeight, 10) < 500,
+     `The iframe is smaller then 500px (${options2Sizes.iframeHeight})`);
+
+  is(options2Sizes.iframeHeight, options2Sizes.documentHeight + "px",
+     "The second addon options page has the expected height");
+
+  await ContentTask.spawn(BrowserApp.selectedTab.browser, null, navigateBack);
+
+  const backToOptionsSizes =  await ContentTask.spawn(
+    BrowserApp.selectedTab.browser, [addonID, "Options page 1"], waitAddonOptionsPage
+  );
+
+  // After going back to the first options page,
+  // we expect the iframe to have the same size of the previous load.
+  is(backToOptionsSizes.iframeHeight, optionsSizes.iframeHeight,
+     `When navigating back, the old iframe size is restored (${backToOptionsSizes.iframeHeight})`);
+
+  BrowserApp.closeTab(BrowserApp.selectedTab);
+
+  await extension.unload();
+});
+
+</script>
+
+</body>
+</html>
--- a/mobile/android/themes/core/aboutAddons.css
+++ b/mobile/android/themes/core/aboutAddons.css
@@ -44,16 +44,19 @@ a:active {
   width: 100%;
   overflow: hidden;
   white-space: nowrap;
   text-overflow: ellipsis;
 }
 
 .warn-unsigned {
   border-top: 1px solid var(--color_about_item_border);
+  border-bottom: 1px solid var(--color_about_item_border);
+  margin-top: 1em;
+  margin-bottom: 1em;
   padding: 1em;
   padding-inline-start: calc(var(--icon-size) + var(--icon-margin) * 2);
   background-image: url("chrome://browser/skin/images/grey-caution.svg");
   background-size: var(--icon-size);
   background-position: var(--icon-margin);
   background-repeat: no-repeat;
   display: none;
 }