diff --git a/browser/modules/UITour.jsm b/browser/modules/UITour.jsm index c6fc39e4c3ea..09b812ea3a33 100644 --- a/browser/modules/UITour.jsm +++ b/browser/modules/UITour.jsm @@ -2,17 +2,22 @@ // 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"; + this.EXPORTED_SYMBOLS = ["UITour"]; const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager", "resource://gre/modules/LightweightThemeManager.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PermissionsUtils", "resource://gre/modules/PermissionsUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI", + "resource:///modules/CustomizableUI.jsm"); const UITOUR_PERMISSION = "uitour"; @@ -23,20 +28,45 @@ this.UITour = { originTabs: new WeakMap(), pinnedTabs: new WeakMap(), urlbarCapture: new WeakMap(), + appMenuOpenForAnnotation: new Set(), highlightEffects: ["wobble", "zoom", "color"], targets: new Map([ - ["backforward", "#back-button"], - ["appmenu", "#PanelUI-menu-button"], - ["home", "#home-button"], - ["urlbar", "#urlbar"], - ["bookmarks", "#bookmarks-menu-button"], - ["search", "#searchbar"], - ["searchprovider", function UITour_target_searchprovider(aDocument) { - let searchbar = aDocument.getElementById("searchbar"); - return aDocument.getAnonymousElementByAttribute(searchbar, - "anonid", - "searchbar-engine-button"); + ["addons", {query: "#add-ons-button"}], + ["appMenu", {query: "#PanelUI-menu-button"}], + ["backForward", { + query: "#back-button", + widgetName: "urlbar-container", + }], + ["bookmarks", {query: "#bookmarks-menu-button"}], + ["customize", { + query: (aDocument) => { + let customizeButton = aDocument.getElementById("PanelUI-customize"); + return aDocument.getAnonymousElementByAttribute(customizeButton, + "class", + "toolbarbutton-icon"); + }, + widgetName: "PanelUI-customize", + }], + ["help", {query: "#PanelUI-help"}], + ["home", {query: "#home-button"}], + ["quit", {query: "#PanelUI-quit"}], + ["search", { + query: "#searchbar", + widgetName: "search-container", + }], + ["searchProvider", { + query: (aDocument) => { + let searchbar = aDocument.getElementById("searchbar"); + return aDocument.getAnonymousElementByAttribute(searchbar, + "anonid", + "searchbar-engine-button"); + }, + widgetName: "search-container", + }], + ["urlbar", { + query: "#urlbar", + widgetName: "urlbar-container", }], ]), @@ -68,10 +98,14 @@ this.UITour = { switch (action) { case "showHighlight": { - let target = this.getTarget(window, data.target); - if (!target) - return false; - this.showHighlight(target); + let targetPromise = this.getTarget(window, data.target); + targetPromise.then(target => { + if (!target.node) { + Cu.reportError("UITour: Target could not be resolved: " + data.target); + return; + } + this.showHighlight(target); + }).then(null, Cu.reportError); break; } @@ -81,10 +115,14 @@ this.UITour = { } case "showInfo": { - let target = this.getTarget(window, data.target, true); - if (!target) - return false; - this.showInfo(target, data.title, data.text); + let targetPromise = this.getTarget(window, data.target, true); + targetPromise.then(target => { + if (!target.node) { + Cu.reportError("UITour: Target could not be resolved: " + data.target); + return; + } + this.showInfo(target, data.title, data.text); + }).then(null, Cu.reportError); break; } @@ -224,6 +262,7 @@ this.UITour = { if (!aWindowClosing) { this.hideHighlight(aWindow); this.hideInfo(aWindow); + aWindow.PanelUI.panel.removeAttribute("noautohide"); } this.endUrlbarCapture(aWindow); @@ -273,20 +312,93 @@ this.UITour = { }, getTarget: function(aWindow, aTargetName, aSticky = false) { - if (typeof aTargetName != "string" || !aTargetName) - return null; + let deferred = Promise.defer(); + if (typeof aTargetName != "string" || !aTargetName) { + deferred.reject("Invalid target name specified"); + return deferred.promise; + } - if (aTargetName == "pinnedtab") - return this.ensurePinnedTab(aWindow, aSticky); + if (aTargetName == "pinnedTab") { + deferred.resolve({node: this.ensurePinnedTab(aWindow, aSticky)}); + return deferred.promise; + } - let targetQuery = this.targets.get(aTargetName); - if (!targetQuery) - return null; + let targetObject = this.targets.get(aTargetName); + if (!targetObject) { + deferred.reject("The specified target name is not in the allowed set"); + return deferred.promise; + } - if (typeof targetQuery == "function") - return targetQuery(aWindow.document); + let targetQuery = targetObject.query; + aWindow.PanelUI.ensureReady().then(() => { + if (typeof targetQuery == "function") { + deferred.resolve({ + node: targetQuery(aWindow.document), + widgetName: targetObject.widgetName, + }); + return; + } + + deferred.resolve({ + node: aWindow.document.querySelector(targetQuery), + widgetName: targetObject.widgetName, + }); + }).then(null, Cu.reportError); + return deferred.promise; + }, + + targetIsInAppMenu: function(aTarget) { + let placement = CustomizableUI.getPlacementOfWidget(aTarget.widgetName || aTarget.node.id); + if (placement && placement.area == CustomizableUI.AREA_PANEL) { + return true; + } + + let targetElement = aTarget.node; + // Use the widget for filtering if it exists since the target may be the icon inside. + if (aTarget.widgetName) { + targetElement = aTarget.node.ownerDocument.getElementById(aTarget.widgetName); + } + + // Handle the non-customizable buttons at the bottom of the menu which aren't proper widgets. + return targetElement.id.startsWith("PanelUI-") + && targetElement.id != "PanelUI-menu-button"; + }, + + /** + * Called before opening or after closing a highlight or info panel to see if + * we need to open or close the appMenu to see the annotation's anchor. + */ + _setAppMenuStateForAnnotation: function(aWindow, aAnnotationType, aShouldOpenForHighlight, aCallback = null) { + // If the panel is in the desired state, we're done. + let panelIsOpen = aWindow.PanelUI.panel.state != "closed"; + if (aShouldOpenForHighlight == panelIsOpen) { + if (aCallback) + aCallback(); + return; + } + + // Don't close the menu if it wasn't opened by us (e.g. via showmenu instead). + if (!aShouldOpenForHighlight && !this.appMenuOpenForAnnotation.has(aAnnotationType)) { + if (aCallback) + aCallback(); + return; + } + + if (aShouldOpenForHighlight) { + this.appMenuOpenForAnnotation.add(aAnnotationType); + } else { + this.appMenuOpenForAnnotation.delete(aAnnotationType); + } + + // Actually show or hide the menu + if (this.appMenuOpenForAnnotation.size) { + this.showMenu(aWindow, "appMenu", aCallback); + } else { + this.hideMenu(aWindow, "appMenu"); + if (aCallback) + aCallback(); + } - return aWindow.document.querySelector(targetQuery); }, previewTheme: function(aTheme) { @@ -331,24 +443,30 @@ this.UITour = { }, showHighlight: function(aTarget) { - let highlighter = aTarget.ownerDocument.getElementById("UITourHighlight"); + function showHighlightPanel(aTargetEl) { + let highlighter = aTargetEl.ownerDocument.getElementById("UITourHighlight"); - let randomEffect = Math.floor(Math.random() * this.highlightEffects.length); - if (randomEffect == this.highlightEffects.length) - randomEffect--; // On the order of 1 in 2^62 chance of this happening. - highlighter.setAttribute("active", this.highlightEffects[randomEffect]); + let randomEffect = Math.floor(Math.random() * this.highlightEffects.length); + if (randomEffect == this.highlightEffects.length) + randomEffect--; // On the order of 1 in 2^62 chance of this happening. + highlighter.setAttribute("active", this.highlightEffects[randomEffect]); - let targetRect = aTarget.getBoundingClientRect(); + let targetRect = aTargetEl.getBoundingClientRect(); - highlighter.style.height = targetRect.height + "px"; - highlighter.style.width = targetRect.width + "px"; + highlighter.style.height = targetRect.height + "px"; + highlighter.style.width = targetRect.width + "px"; - let highlighterRect = highlighter.getBoundingClientRect(); + let highlighterRect = highlighter.getBoundingClientRect(); - let top = targetRect.top + (targetRect.height / 2) - (highlighterRect.height / 2); - highlighter.style.top = top + "px"; - let left = targetRect.left + (targetRect.width / 2) - (highlighterRect.width / 2); - highlighter.style.left = left + "px"; + let top = targetRect.top + (targetRect.height / 2) - (highlighterRect.height / 2); + highlighter.style.top = top + "px"; + let left = targetRect.left + (targetRect.width / 2) - (highlighterRect.width / 2); + highlighter.style.left = left + "px"; + } + + this._setAppMenuStateForAnnotation(aTarget.node.ownerDocument.defaultView, "highlight", + this.targetIsInAppMenu(aTarget), + showHighlightPanel.bind(this, aTarget.node)); }, hideHighlight: function(aWindow) { @@ -358,44 +476,66 @@ this.UITour = { let highlighter = aWindow.document.getElementById("UITourHighlight"); highlighter.removeAttribute("active"); + + this._setAppMenuStateForAnnotation(aWindow, "highlight", false); }, showInfo: function(aAnchor, aTitle, aDescription) { - aAnchor.focus(); + function showInfoPanel(aAnchorEl) { + aAnchorEl.focus(); - let document = aAnchor.ownerDocument; - let tooltip = document.getElementById("UITourTooltip"); - let tooltipTitle = document.getElementById("UITourTooltipTitle"); - let tooltipDesc = document.getElementById("UITourTooltipDescription"); + let document = aAnchorEl.ownerDocument; + let tooltip = document.getElementById("UITourTooltip"); + let tooltipTitle = document.getElementById("UITourTooltipTitle"); + let tooltipDesc = document.getElementById("UITourTooltipDescription"); - tooltip.hidePopup(); + tooltip.hidePopup(); - tooltipTitle.textContent = aTitle; - tooltipDesc.textContent = aDescription; + tooltipTitle.textContent = aTitle; + tooltipDesc.textContent = aDescription; - let alignment = "bottomcenter topright"; - let anchorRect = aAnchor.getBoundingClientRect(); + let alignment = "bottomcenter topright"; - tooltip.hidden = false; - tooltip.openPopup(aAnchor, alignment); + tooltip.hidden = false; + tooltip.openPopup(aAnchorEl, alignment); + } + + this._setAppMenuStateForAnnotation(aAnchor.node.ownerDocument.defaultView, "info", + this.targetIsInAppMenu(aAnchor), + showInfoPanel.bind(this, aAnchor.node)); }, hideInfo: function(aWindow) { let tooltip = aWindow.document.getElementById("UITourTooltip"); tooltip.hidePopup(); + this._setAppMenuStateForAnnotation(aWindow, "info", false); }, - showMenu: function(aWindow, aMenuName) { + showMenu: function(aWindow, aMenuName, aOpenCallback = null) { function openMenuButton(aId) { let menuBtn = aWindow.document.getElementById(aId); - if (menuBtn && menuBtn.boxObject) - menuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(true); + if (!menuBtn || !menuBtn.boxObject) { + aOpenCallback(); + return; + } + if (aOpenCallback) + menuBtn.addEventListener("popupshown", onPopupShown); + menuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(true); + } + function onPopupShown(event) { + this.removeEventListener("popupshown", onPopupShown); + aOpenCallback(event); } - if (aMenuName == "appmenu") + if (aMenuName == "appMenu") { + aWindow.PanelUI.panel.setAttribute("noautohide", "true"); + if (aOpenCallback) { + aWindow.PanelUI.panel.addEventListener("popupshown", onPopupShown); + } aWindow.PanelUI.show(); - else if (aMenuName == "bookmarks") + } else if (aMenuName == "bookmarks") { openMenuButton("bookmarks-menu-button"); + } }, hideMenu: function(aWindow, aMenuName) { @@ -405,10 +545,12 @@ this.UITour = { menuBtn.boxObject.QueryInterface(Ci.nsIMenuBoxObject).openMenu(false); } - if (aMenuName == "appmenu") + if (aMenuName == "appMenu") { + aWindow.PanelUI.panel.removeAttribute("noautohide"); aWindow.PanelUI.hide(); - else if (aMenuName == "bookmarks") + } else if (aMenuName == "bookmarks") { closeMenuButton("bookmarks-menu-button"); + } }, startUrlbarCapture: function(aWindow, aExpectedText, aUrl) { diff --git a/browser/modules/test/browser.ini b/browser/modules/test/browser.ini index e03187b1a7dd..4a2b49e79454 100644 --- a/browser/modules/test/browser.ini +++ b/browser/modules/test/browser.ini @@ -1,4 +1,6 @@ [DEFAULT] +support-files = + head.js [browser_NetworkPrioritizer.js] [browser_SignInToWebsite.js] diff --git a/browser/modules/test/browser_UITour.js b/browser/modules/test/browser_UITour.js index a960bfe92af4..d8d811890f15 100644 --- a/browser/modules/test/browser_UITour.js +++ b/browser/modules/test/browser_UITour.js @@ -27,6 +27,25 @@ function is_element_visible(element, msg) { ok(!is_hidden(element), msg); } +function waitForElementToBeVisible(element, nextTest, msg) { + waitForCondition(() => !is_hidden(element), + () => { + ok(true, msg); + nextTest(); + }, + "Timeout waiting for visibility: " + msg); +} + +function waitForPopupAtAnchor(popup, anchorNode, nextTest, msg) { + waitForCondition(() => popup.popupBoxObject.anchorNode == anchorNode, + () => { + ok(true, msg); + is_element_visible(popup); + nextTest(); + }, + "Timeout waiting for popup at anchor: " + msg); +} + function is_element_hidden(element, msg) { isnot(element, null, "Element should not be null, when checking visibility"); ok(is_hidden(element), msg); @@ -80,6 +99,8 @@ function test() { let popup = document.getElementById("UITourTooltip"); isnot(["hidding","closed"].indexOf(popup.state), -1, "Popup should be closed/hidding after UITour tab is closed"); + ok(!PanelUI.panel.hasAttribute("noautohide"), "@noautohide on the menu panel should have been cleaned up"); + is(UITour.pinnedTabs.get(window), null, "Any pinned tab should be closed after UITour tab is closed"); executeSoon(nextTest); @@ -102,22 +123,22 @@ function test() { let tests = [ function test_untrusted_host(done) { loadTestPage(function() { - let highlight = document.getElementById("UITourHighlight"); - is_element_hidden(highlight, "Highlight should initially be hidden"); + let bookmarksMenu = document.getElementById("bookmarks-menu-button"); + ise(bookmarksMenu.open, false, "Bookmark menu should initially be closed"); - gContentAPI.showHighlight("urlbar"); - is_element_hidden(highlight, "Highlight should not be shown on a untrusted host"); + gContentAPI.showMenu("bookmarks"); + ise(bookmarksMenu.open, false, "Bookmark menu should not open on a untrusted host"); done(); }, "http://mochi.test:8888/"); }, function test_unsecure_host(done) { loadTestPage(function() { - let highlight = document.getElementById("UITourHighlight"); - is_element_hidden(highlight, "Highlight should initially be hidden"); + let bookmarksMenu = document.getElementById("bookmarks-menu-button"); + ise(bookmarksMenu.open, false, "Bookmark menu should initially be closed"); - gContentAPI.showHighlight("urlbar"); - is_element_hidden(highlight, "Highlight should not be shown on a unsecure host"); + gContentAPI.showMenu("bookmarks"); + ise(bookmarksMenu.open, false, "Bookmark menu should not open on a unsecure host"); done(); }, "http://example.com/"); @@ -129,40 +150,78 @@ let tests = [ is_element_hidden(highlight, "Highlight should initially be hidden"); gContentAPI.showHighlight("urlbar"); - is_element_visible(highlight, "Highlight should be shown on a unsecure host when override pref is set"); + waitForElementToBeVisible(highlight, done, "Highlight should be shown on a unsecure host when override pref is set"); Services.prefs.setBoolPref("browser.uitour.requireSecure", true); - done(); }, "http://example.com/"); }, function test_disabled(done) { Services.prefs.setBoolPref("browser.uitour.enabled", false); - let highlight = document.getElementById("UITourHighlight"); - is_element_hidden(highlight, "Highlight should initially be hidden"); + let bookmarksMenu = document.getElementById("bookmarks-menu-button"); + ise(bookmarksMenu.open, false, "Bookmark menu should initially be closed"); - gContentAPI.showHighlight("urlbar"); - is_element_hidden(highlight, "Highlight should not be shown when feature is disabled"); + gContentAPI.showMenu("bookmarks"); + ise(bookmarksMenu.open, false, "Bookmark menu should not open when feature is disabled"); Services.prefs.setBoolPref("browser.uitour.enabled", true); done(); }, function test_highlight(done) { + function test_highlight_2() { + let highlight = document.getElementById("UITourHighlight"); + gContentAPI.hideHighlight(); + is_element_hidden(highlight, "Highlight should be hidden after hideHighlight()"); + + gContentAPI.showHighlight("urlbar"); + waitForElementToBeVisible(highlight, test_highlight_3, "Highlight should be shown after showHighlight()"); + } + function test_highlight_3() { + let highlight = document.getElementById("UITourHighlight"); + gContentAPI.showHighlight("backForward"); + waitForElementToBeVisible(highlight, done, "Highlight should be shown after showHighlight()"); + } + let highlight = document.getElementById("UITourHighlight"); is_element_hidden(highlight, "Highlight should initially be hidden"); gContentAPI.showHighlight("urlbar"); - is_element_visible(highlight, "Highlight should be shown after showHighlight()"); + waitForElementToBeVisible(highlight, test_highlight_2, "Highlight should be shown after showHighlight()"); + }, + function test_highlight_customize_auto_open_close(done) { + let highlight = document.getElementById("UITourHighlight"); + gContentAPI.showHighlight("customize"); + waitForElementToBeVisible(highlight, function checkPanelIsOpen() { + isnot(PanelUI.panel.state, "closed", "Panel should have opened"); - gContentAPI.hideHighlight(); - is_element_hidden(highlight, "Highlight should be hidden after hideHighlight()"); + // Move the highlight outside which should close the app menu. + gContentAPI.showHighlight("appMenu"); + waitForElementToBeVisible(highlight, function checkPanelIsClosed() { + isnot(PanelUI.panel.state, "open", + "Panel should have closed after the highlight moved elsewhere."); + done(); + }, "Highlight should move to the appMenu button"); + }, "Highlight should be shown after showHighlight() for fixed panel items"); + }, + function test_highlight_customize_manual_open_close(done) { + let highlight = document.getElementById("UITourHighlight"); + // Manually open the app menu then show a highlight there. The menu should remain open. + gContentAPI.showMenu("appMenu"); + isnot(PanelUI.panel.state, "closed", "Panel should have opened"); + gContentAPI.showHighlight("customize"); - gContentAPI.showHighlight("urlbar"); - is_element_visible(highlight, "Highlight should be shown after showHighlight()"); - gContentAPI.showHighlight("backforward"); - is_element_visible(highlight, "Highlight should be shown after showHighlight()"); + waitForElementToBeVisible(highlight, function checkPanelIsStillOpen() { + isnot(PanelUI.panel.state, "closed", "Panel should still be open"); - done(); + // Move the highlight outside which shouldn't close the app menu since it was manually opened. + gContentAPI.showHighlight("appMenu"); + waitForElementToBeVisible(highlight, function () { + isnot(PanelUI.panel.state, "closed", + "Panel should remain open since UITour didn't open it in the first place"); + gContentAPI.hideMenu("appMenu"); + done(); + }, "Highlight should move to the appMenu button"); + }, "Highlight should be shown after showHighlight() for fixed panel items"); }, function test_info_1(done) { let popup = document.getElementById("UITourTooltip"); @@ -212,6 +271,54 @@ let tests = [ gContentAPI.showInfo("urlbar", "urlbar title", "urlbar text"); }, + function test_info_customize_auto_open_close(done) { + let popup = document.getElementById("UITourTooltip"); + gContentAPI.showInfo("customize", "Customization", "Customize me please!"); + UITour.getTarget(window, "customize").then((customizeTarget) => { + waitForPopupAtAnchor(popup, customizeTarget.node, function checkPanelIsOpen() { + isnot(PanelUI.panel.state, "closed", "Panel should have opened before the popup anchored"); + ok(PanelUI.panel.hasAttribute("noautohide"), "@noautohide on the menu panel should have been set"); + + // Move the info outside which should close the app menu. + gContentAPI.showInfo("appMenu", "Open Me", "You know you want to"); + UITour.getTarget(window, "appMenu").then((target) => { + waitForPopupAtAnchor(popup, target.node, function checkPanelIsClosed() { + isnot(PanelUI.panel.state, "open", + "Panel should have closed after the info moved elsewhere."); + ok(!PanelUI.panel.hasAttribute("noautohide"), "@noautohide on the menu panel should have been cleaned up on close"); + done(); + }, "Info should move to the appMenu button"); + }); + }, "Info panel should be anchored to the customize button"); + }); + }, + function test_info_customize_manual_open_close(done) { + let popup = document.getElementById("UITourTooltip"); + // Manually open the app menu then show an info panel there. The menu should remain open. + gContentAPI.showMenu("appMenu"); + isnot(PanelUI.panel.state, "closed", "Panel should have opened"); + ok(PanelUI.panel.hasAttribute("noautohide"), "@noautohide on the menu panel should have been set"); + gContentAPI.showInfo("customize", "Customization", "Customize me please!"); + + UITour.getTarget(window, "customize").then((customizeTarget) => { + waitForPopupAtAnchor(popup, customizeTarget.node, function checkMenuIsStillOpen() { + isnot(PanelUI.panel.state, "closed", "Panel should still be open"); + ok(PanelUI.panel.hasAttribute("noautohide"), "@noautohide on the menu panel should still be set"); + + // Move the info outside which shouldn't close the app menu since it was manually opened. + gContentAPI.showInfo("appMenu", "Open Me", "You know you want to"); + UITour.getTarget(window, "appMenu").then((target) => { + waitForPopupAtAnchor(popup, target.node, function checkMenuIsStillOpen() { + isnot(PanelUI.panel.state, "closed", + "Menu should remain open since UITour didn't open it in the first place"); + gContentAPI.hideMenu("appMenu"); + ok(!PanelUI.panel.hasAttribute("noautohide"), "@noautohide on the menu panel should have been cleaned up on close"); + done(); + }, "Info should move to the appMenu button"); + }); + }, "Info should be shown after showInfo() for fixed menu panel items"); + }); + }, function test_pinnedTab(done) { is(UITour.pinnedTabs.get(window), null, "Should not already have a pinned tab"); diff --git a/browser/modules/test/head.js b/browser/modules/test/head.js new file mode 100644 index 000000000000..f16d22664cf6 --- /dev/null +++ b/browser/modules/test/head.js @@ -0,0 +1,24 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function waitForCondition(condition, nextTest, errorMsg) { + var tries = 0; + var interval = setInterval(function() { + if (tries >= 30) { + ok(false, errorMsg); + moveOn(); + } + var conditionPassed; + try { + conditionPassed = condition(); + } catch (e) { + ok(false, e + "\n" + e.stack); + conditionPassed = false; + } + if (conditionPassed) { + moveOn(); + } + tries++; + }, 100); + var moveOn = function() { clearInterval(interval); nextTest(); }; +}