diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
index dae45b01d872..6ae76de3b881 100644
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1039,6 +1039,9 @@ pref("devtools.inspector.highlighterShowInfobar", true);
pref("devtools.layoutview.enabled", true);
pref("devtools.layoutview.open", false);
+// Enable the Responsive UI tool
+pref("devtools.responsiveUI.enabled", true);
+
// Enable the Debugger
pref("devtools.debugger.enabled", true);
pref("devtools.debugger.remote-enabled", false);
diff --git a/browser/base/content/browser-appmenu.inc b/browser/base/content/browser-appmenu.inc
index d47043872aea..06d2abb3dc4f 100644
--- a/browser/base/content/browser-appmenu.inc
+++ b/browser/base/content/browser-appmenu.inc
@@ -160,6 +160,12 @@
type="checkbox"
command="Tools:Inspect"
key="key_inspect"/>
+
+
+
@@ -250,6 +251,13 @@
modifiers="accel,alt"
#else
modifiers="accel,shift"
+#endif
+ />
+
+
-
-
-
+
+
+
+
+
@@ -290,6 +292,15 @@
+
+
+
+
+
+
+
(original author)
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+var EXPORTED_SYMBOLS = ["ResponsiveUIManager"];
+
+const MIN_WIDTH = 50;
+const MIN_HEIGHT = 50;
+
+let ResponsiveUIManager = {
+ /**
+ * Check if the a tab is in a responsive mode.
+ * Leave the responsive mode if active,
+ * active the responsive mode if not active.
+ *
+ * @param aWindow the main window.
+ * @param aTab the tab targeted.
+ */
+ toggle: function(aWindow, aTab) {
+ if (aTab.responsiveUI) {
+ aTab.responsiveUI.close();
+ } else {
+ aTab.responsiveUI = new ResponsiveUI(aWindow, aTab);
+ }
+ },
+}
+
+let presets = [
+ // Phones
+ {width: 320, height: 480}, // iPhone, B2G, with
+ {width: 360, height: 640}, // Android 4, phones, with
+
+ // Tablets
+ {width: 768, height: 1024}, // iPad, with
+ {width: 800, height: 1280}, // Android 4, Tablet, with
+
+ // Default width for mobile browsers, no
+ {width: 980, height: 1280},
+
+ // Computer
+ {width: 1280, height: 600},
+ {width: 1920, height: 900},
+];
+
+function ResponsiveUI(aWindow, aTab)
+{
+ this.mainWindow = aWindow;
+ this.tab = aTab;
+ this.browser = aTab.linkedBrowser;
+ this.chromeDoc = aWindow.document;
+ this.container = aWindow.gBrowser.getBrowserContainer(this.browser);
+ this.stack = this.container.querySelector("[anonid=browserStack]");
+
+ // Try to load presets from prefs
+ if (Services.prefs.prefHasUserValue("devtools.responsiveUI.presets")) {
+ try {
+ presets = JSON.parse(Services.prefs.getCharPref("devtools.responsiveUI.presets"));
+ } catch(e) {
+ // User pref is malformated.
+ Cu.reportError("Could not parse pref `devtools.responsiveUI.presets`: " + e);
+ }
+ }
+
+ if (Array.isArray(presets)) {
+ this.presets = [{custom: true}].concat(presets)
+ } else {
+ Cu.reportError("Presets value (devtools.responsiveUI.presets) is malformated.");
+ this.presets = [{custom: true}];
+ }
+
+ // Default size. The first preset (custom) is the one that will be used.
+ let bbox = this.stack.getBoundingClientRect();
+ this.presets[0].width = bbox.width - 40; // horizontal padding of the container
+ this.presets[0].height = bbox.height - 80; // vertical padding + toolbar height
+ this.currentPreset = 0; // Custom
+
+ this.container.setAttribute("responsivemode", "true");
+ this.stack.setAttribute("responsivemode", "true");
+
+ // Let's bind some callbacks.
+ this.bound_presetSelected = this.presetSelected.bind(this);
+ this.bound_rotate = this.rotate.bind(this);
+ this.bound_startResizing = this.startResizing.bind(this);
+ this.bound_stopResizing = this.stopResizing.bind(this);
+ this.bound_onDrag = this.onDrag.bind(this);
+ this.bound_onKeypress = this.onKeypress.bind(this);
+
+ // Events
+ this.tab.addEventListener("TabClose", this);
+ this.tab.addEventListener("TabAttrModified", this);
+ this.mainWindow.addEventListener("keypress", this.bound_onKeypress, true);
+
+ this.buildUI();
+ this.checkMenus();
+}
+
+ResponsiveUI.prototype = {
+ /**
+ * Destroy the nodes. Remove listeners. Reset the style.
+ */
+ close: function RUI_unload() {
+ this.unCheckMenus();
+ // Reset style of the stack.
+ let style = "max-width: none;" +
+ "min-width: 0;" +
+ "max-height: none;" +
+ "min-height: 0;";
+ this.stack.setAttribute("style", style);
+
+ this.stopResizing();
+
+ // Remove listeners.
+ this.mainWindow.removeEventListener("keypress", this.bound_onKeypress, true);
+ this.menulist.removeEventListener("select", this.bound_presetSelected, true);
+ this.tab.removeEventListener("TabClose", this);
+ this.tab.removeEventListener("TabAttrModified", this);
+ this.rotatebutton.removeEventListener("command", this.bound_rotate, true);
+
+ // Removed elements.
+ this.container.removeChild(this.toolbar);
+ this.stack.removeChild(this.resizer);
+ this.stack.removeChild(this.resizeBar);
+
+ // Unset the responsive mode.
+ this.container.removeAttribute("responsivemode");
+ this.stack.removeAttribute("responsivemode");
+
+ delete this.tab.responsiveUI;
+ },
+
+ /**
+ * Handle keypressed.
+ *
+ * @param aEvent
+ */
+ onKeypress: function RUI_onKeypress(aEvent) {
+ if (aEvent.keyCode == this.mainWindow.KeyEvent.DOM_VK_ESCAPE &&
+ this.mainWindow.gBrowser.selectedBrowser == this.browser) {
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+ this.close();
+ }
+ },
+
+ /**
+ * Handle events
+ */
+ handleEvent: function (aEvent) {
+ switch (aEvent.type) {
+ case "TabClose":
+ this.close();
+ break;
+ case "TabAttrModified":
+ if (this.mainWindow.gBrowser.selectedBrowser == this.browser) {
+ this.checkMenus();
+ } else {
+ this.unCheckMenus();
+ }
+ break;
+ }
+ },
+
+ /**
+ * Check the menu items.
+ */
+ checkMenus: function RUI_checkMenus() {
+ this.chromeDoc.getElementById("Tools:ResponsiveUI").setAttribute("checked", "true");
+ },
+
+ /**
+ * Uncheck the menu items.
+ */
+ unCheckMenus: function RUI_unCheckMenus() {
+ this.chromeDoc.getElementById("Tools:ResponsiveUI").setAttribute("checked", "false");
+ },
+
+ /**
+ * Build the toolbar and the resizers.
+ *
+ * From tabbrowser.xml
+ *
+ * // presets
+ * // rotate
+ *
+ * From tabbrowser.xml
+ *
+ *
+ *
+ *
+ *
+ */
+ buildUI: function RUI_buildUI() {
+ // Toolbar
+ this.toolbar = this.chromeDoc.createElement("toolbar");
+ this.toolbar.className = "devtools-toolbar devtools-responsiveui-toolbar";
+
+ this.menulist = this.chromeDoc.createElement("menulist");
+ this.menulist.className = "devtools-menulist";
+
+ this.menulist.addEventListener("select", this.bound_presetSelected, true);
+
+ let menupopup = this.chromeDoc.createElement("menupopup");
+ this.registerPresets(menupopup);
+ this.menulist.appendChild(menupopup);
+
+ this.rotatebutton = this.chromeDoc.createElement("toolbarbutton");
+ this.rotatebutton.setAttribute("tabindex", "0");
+ this.rotatebutton.setAttribute("label", this.strings.GetStringFromName("responsiveUI.rotate"));
+ this.rotatebutton.className = "devtools-toolbarbutton";
+ this.rotatebutton.addEventListener("command", this.bound_rotate, true);
+
+ this.toolbar.appendChild(this.menulist);
+ this.toolbar.appendChild(this.rotatebutton);
+
+ // Resizers
+ this.resizer = this.chromeDoc.createElement("box");
+ this.resizer.className = "devtools-responsiveui-resizehandle";
+ this.resizer.setAttribute("right", "0");
+ this.resizer.setAttribute("bottom", "0");
+ this.resizer.onmousedown = this.bound_startResizing;
+
+ this.resizeBar = this.chromeDoc.createElement("box");
+ this.resizeBar.className = "devtools-responsiveui-resizebar";
+ this.resizeBar.setAttribute("top", "0");
+ this.resizeBar.setAttribute("right", "0");
+ this.resizeBar.onmousedown = this.bound_startResizing;
+
+ this.container.insertBefore(this.toolbar, this.stack);
+ this.stack.appendChild(this.resizer);
+ this.stack.appendChild(this.resizeBar);
+ },
+
+ /**
+ * Build the presets list and append it to the menupopup.
+ *
+ * @param aParent menupopup.
+ */
+ registerPresets: function RUI_registerPresets(aParent) {
+ let fragment = this.chromeDoc.createDocumentFragment();
+ let doc = this.chromeDoc;
+ let self = this;
+ this.presets.forEach(function(preset) {
+ let menuitem = doc.createElement("menuitem");
+ self.setMenuLabel(menuitem, preset);
+ fragment.appendChild(menuitem);
+ });
+ aParent.appendChild(fragment);
+ },
+
+ /**
+ * Set the menuitem label of a preset.
+ *
+ * @param aMenuitem menuitem to edit.
+ * @param aPreset associated preset.
+ */
+ setMenuLabel: function RUI_setMenuLabel(aMenuitem, aPreset) {
+ let size = Math.round(aPreset.width) + "x" + Math.round(aPreset.height);
+ if (aPreset.custom) {
+ let str = this.strings.formatStringFromName("responsiveUI.customResolution", [size], 1);
+ aMenuitem.setAttribute("label", str);
+ } else {
+ aMenuitem.setAttribute("label", size);
+ }
+ },
+
+ /**
+ * When a preset is selected, apply it.
+ */
+ presetSelected: function RUI_presetSelected() {
+ this.currentPreset = this.menulist.selectedIndex;
+ let preset = this.presets[this.currentPreset];
+ this.loadPreset(preset);
+ },
+
+ /**
+ * Apply a preset.
+ *
+ * @param aPreset preset to apply.
+ */
+ loadPreset: function RUI_loadPreset(aPreset) {
+ this.setSize(aPreset.width, aPreset.height);
+ },
+
+ /**
+ * Swap width and height.
+ */
+ rotate: function RUI_rotate() {
+ this.setSize(this.currentHeight, this.currentWidth);
+ },
+
+ /**
+ * Change the size of the browser.
+ *
+ * @param aWidth width of the browser.
+ * @param aHeight height of the browser.
+ */
+ setSize: function RUI_setSize(aWidth, aHeight) {
+ this.currentWidth = aWidth;
+ this.currentHeight = aHeight;
+
+ // We resize the containing stack.
+ let style = "max-width: %width;" +
+ "min-width: %width;" +
+ "max-height: %height;" +
+ "min-height: %height;";
+
+ style = style.replace(/%width/g, this.currentWidth + "px");
+ style = style.replace(/%height/g, this.currentHeight + "px");
+
+ this.stack.setAttribute("style", style);
+
+ if (!this.ignoreY)
+ this.resizeBar.setAttribute("top", Math.round(this.currentHeight / 2));
+
+ // We uptate the Custom menuitem if we are not using a preset.
+ if (this.presets[this.currentPreset].custom) {
+ let preset = this.presets[this.currentPreset];
+ preset.width = this.currentWidth;
+ preset.height = this.currentHeight;
+
+ let menuitem = this.menulist.firstChild.childNodes[this.currentPreset];
+ this.setMenuLabel(menuitem, preset);
+ }
+ },
+
+ /**
+ * Start the process of resizing the browser.
+ *
+ * @param aEvent
+ */
+ startResizing: function RUI_startResizing(aEvent) {
+ let preset = this.presets[this.currentPreset];
+ if (!preset.custom) {
+ this.currentPreset = 0;
+ preset = this.presets[0];
+ preset.width = this.currentWidth;
+ preset.height = this.currentHeight;
+ let menuitem = this.menulist.firstChild.childNodes[0];
+ this.setMenuLabel(menuitem, preset);
+ this.menulist.selectedIndex = 0;
+ }
+ this.mainWindow.addEventListener("mouseup", this.bound_stopResizing, true);
+ this.mainWindow.addEventListener("mousemove", this.bound_onDrag, true);
+ this.container.style.pointerEvents = "none";
+
+ this.stack.setAttribute("notransition", "true");
+
+ this.lastClientX = aEvent.clientX;
+ this.lastClientY = aEvent.clientY;
+
+ this.ignoreY = (aEvent.target === this.resizeBar);
+ },
+
+ /**
+ * Resizing on mouse move.
+ *
+ * @param aEvent
+ */
+ onDrag: function RUI_onDrag(aEvent) {
+ let deltaX = aEvent.clientX - this.lastClientX;
+ let deltaY = aEvent.clientY - this.lastClientY;
+
+ if (this.ignoreY)
+ deltaY = 0;
+
+ let width = this.currentWidth + deltaX;
+ let height = this.currentHeight + deltaY;
+
+ if (width < MIN_WIDTH) {
+ width = MIN_WIDTH;
+ } else {
+ this.lastClientX = aEvent.clientX;
+ }
+
+ if (height < MIN_HEIGHT) {
+ height = MIN_HEIGHT;
+ } else {
+ this.lastClientY = aEvent.clientY;
+ }
+
+ this.setSize(width, height);
+ },
+
+ /**
+ * Stop End resizing
+ */
+ stopResizing: function RUI_stopResizing() {
+ this.container.style.pointerEvents = "auto";
+
+ this.mainWindow.removeEventListener("mouseup", this.bound_stopResizing, true);
+ this.mainWindow.removeEventListener("mousemove", this.bound_onDrag, true);
+
+ this.stack.removeAttribute("notransition");
+ this.ignoreY = false;
+ },
+}
+
+XPCOMUtils.defineLazyGetter(ResponsiveUI.prototype, "strings", function () {
+ return Services.strings.createBundle("chrome://browser/locale/devtools/responsiveUI.properties");
+});
diff --git a/browser/devtools/responsivedesign/test/Makefile.in b/browser/devtools/responsivedesign/test/Makefile.in
new file mode 100644
index 000000000000..cbfe66c83836
--- /dev/null
+++ b/browser/devtools/responsivedesign/test/Makefile.in
@@ -0,0 +1,53 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is mozilla.org code.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation.
+# Portions created by the Initial Developer are Copyright (C) 2012
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+# Paul Rouget
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either of the GNU General Public License Version 2 or later (the "GPL"),
+# or the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+DEPTH = ../../../..
+topsrcdir = @top_srcdir@
+srcdir = @srcdir@
+VPATH = @srcdir@
+relativesrcdir = browser/devtools/responsivedesign/test
+
+include $(DEPTH)/config/autoconf.mk
+include $(topsrcdir)/config/rules.mk
+
+_BROWSER_FILES = \
+ browser_responsiveui.js \
+ $(NULL)
+
+
+libs:: $(_BROWSER_FILES)
+ $(INSTALL) $(foreach f,$^,"$f") $(DEPTH)/_tests/testing/mochitest/browser/$(relativesrcdir)
diff --git a/browser/devtools/responsivedesign/test/browser_responsiveui.js b/browser/devtools/responsivedesign/test/browser_responsiveui.js
new file mode 100644
index 000000000000..690f584e3285
--- /dev/null
+++ b/browser/devtools/responsivedesign/test/browser_responsiveui.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function test() {
+ let instance;
+
+ waitForExplicitFinish();
+
+ gBrowser.selectedTab = gBrowser.addTab();
+ gBrowser.selectedBrowser.addEventListener("load", function onload() {
+ gBrowser.selectedBrowser.removeEventListener("load", onload, true);
+ waitForFocus(startTest, content);
+ }, true);
+
+ content.location = "data:text/html,mop";
+
+ function startTest() {
+ document.getElementById("Tools:ResponsiveUI").removeAttribute("disabled");
+ synthesizeKeyFromKeyTag("key_responsiveUI");
+ executeSoon(onUIOpen);
+ }
+
+ function onUIOpen() {
+ // Is it open?
+ let container = gBrowser.getBrowserContainer();
+ is(container.getAttribute("responsivemode"), "true", "In responsive mode.");
+
+ // Menus are correctly updated?
+ is(document.getElementById("Tools:ResponsiveUI").getAttribute("checked"), "true", "menus checked");
+
+ instance = gBrowser.selectedTab.responsiveUI;
+ ok(instance, "instance of the module is attached to the tab.");
+
+ testPresets();
+ }
+
+ function testPresets() {
+ function testOnePreset(c) {
+ if (c == 0) {
+ executeSoon(testCustom);
+ return;
+ }
+ instance.menulist.selectedIndex = c;
+ window.setTimeout(function() {
+ let item = instance.menulist.firstChild.childNodes[c];
+ let [width, height] = extractSizeFromString(item.getAttribute("label"));
+ is(content.innerWidth, width, "preset " + c + ": dimension valid (width)");
+ is(content.innerHeight, height, "preset " + c + ": dimension valid (height)");
+ testOnePreset(c - 1);
+ }, 500);
+ }
+ testOnePreset(instance.menulist.firstChild.childNodes.length - 1);
+ }
+
+ function extractSizeFromString(str) {
+ let numbers = str.match(/(\d+)[^\d]*(\d+)/);
+ if (numbers) {
+ return [numbers[1], numbers[2]];
+ } else {
+ return [null, null];
+ }
+ }
+
+ function testCustom() {
+ let initialWidth = content.innerWidth;
+ let initialHeight = content.innerHeight;
+
+ let x = 2, y = 2;
+ EventUtils.synthesizeMouse(instance.resizer, x, y, {type: "mousedown"}, window);
+ x += 20; y += 10;
+ EventUtils.synthesizeMouse(instance.resizer, x, y, {type: "mousemove"}, window);
+ EventUtils.synthesizeMouse(instance.resizer, x, y, {type: "mouseup"}, window);
+
+ window.setTimeout(function() {
+ let expectedWidth = initialWidth + 20;
+ let expectedHeight = initialHeight + 10;
+ info("initial width: " + initialWidth);
+ info("initial height: " + initialHeight);
+ is(content.innerWidth, expectedWidth, "Size correcty updated (width).");
+ is(content.innerHeight, expectedHeight, "Size correcty updated (height).");
+ is(instance.menulist.selectedIndex, 0, "Custom menuitem selected");
+ let [width, height] = extractSizeFromString(instance.menulist.firstChild.firstChild.getAttribute("label"));
+ is(width, expectedWidth, "Label updated (width).");
+ is(height, expectedHeight, "Label updated (height).");
+ rotate();
+ }, 500);
+ }
+
+ function rotate() {
+ let initialWidth = content.innerWidth;
+ let initialHeight = content.innerHeight;
+
+ info("rotate");
+ instance.rotate();
+
+ window.setTimeout(function() {
+ is(content.innerWidth, initialHeight, "The width is now the height.");
+ is(content.innerHeight, initialWidth, "The height is now the width.");
+ let [width, height] = extractSizeFromString(instance.menulist.firstChild.firstChild.getAttribute("label"));
+ is(width, initialHeight, "Label updated (width).");
+ is(height, initialWidth, "Label updated (height).");
+
+ EventUtils.synthesizeKey("VK_ESCAPE", {});
+ executeSoon(finishUp);
+ }, 500);
+ }
+
+ function finishUp() {
+
+ // Menus are correctly updated?
+ is(document.getElementById("Tools:ResponsiveUI").getAttribute("checked"), "false", "menu unchecked");
+
+ delete instance;
+ gBrowser.removeCurrentTab();
+ finish();
+ }
+
+ function synthesizeKeyFromKeyTag(aKeyId) {
+ let key = document.getElementById(aKeyId);
+ isnot(key, null, "Successfully retrieved the node");
+
+ let modifiersAttr = key.getAttribute("modifiers");
+
+ let name = null;
+
+ if (key.getAttribute("keycode"))
+ name = key.getAttribute("keycode");
+ else if (key.getAttribute("key"))
+ name = key.getAttribute("key");
+
+ isnot(name, null, "Successfully retrieved keycode/key");
+
+ let modifiers = {
+ shiftKey: modifiersAttr.match("shift"),
+ ctrlKey: modifiersAttr.match("ctrl"),
+ altKey: modifiersAttr.match("alt"),
+ metaKey: modifiersAttr.match("meta"),
+ accelKey: modifiersAttr.match("accel")
+ }
+
+ EventUtils.synthesizeKey(name, modifiers);
+ }
+}
diff --git a/browser/locales/en-US/chrome/browser/browser.dtd b/browser/locales/en-US/chrome/browser/browser.dtd
index 7ad22fb12182..1311f4b70b4d 100644
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -214,6 +214,10 @@ These should match what Safari and other Apple applications use on OS X Lion. --
+
+
+
+