summaryrefslogtreecommitdiffstats
path: root/devtools/client/responsive/components
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/client/responsive/components/App.js447
-rw-r--r--devtools/client/responsive/components/Device.js139
-rw-r--r--devtools/client/responsive/components/DeviceAdder.js229
-rw-r--r--devtools/client/responsive/components/DeviceForm.js231
-rw-r--r--devtools/client/responsive/components/DeviceInfo.js52
-rw-r--r--devtools/client/responsive/components/DeviceList.js97
-rw-r--r--devtools/client/responsive/components/DeviceModal.js303
-rw-r--r--devtools/client/responsive/components/DevicePixelRatioMenu.js100
-rw-r--r--devtools/client/responsive/components/DeviceSelector.js173
-rw-r--r--devtools/client/responsive/components/SettingsMenu.js122
-rw-r--r--devtools/client/responsive/components/Toolbar.js216
-rw-r--r--devtools/client/responsive/components/UserAgentInput.js103
-rw-r--r--devtools/client/responsive/components/ViewportDimension.js251
-rw-r--r--devtools/client/responsive/components/moz.build20
14 files changed, 2483 insertions, 0 deletions
diff --git a/devtools/client/responsive/components/App.js b/devtools/client/responsive/components/App.js
new file mode 100644
index 0000000000..42b8f82e26
--- /dev/null
+++ b/devtools/client/responsive/components/App.js
@@ -0,0 +1,447 @@
+/* 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/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const Toolbar = createFactory(
+ require("resource://devtools/client/responsive/components/Toolbar.js")
+);
+
+loader.lazyGetter(this, "DeviceModal", () =>
+ createFactory(
+ require("resource://devtools/client/responsive/components/DeviceModal.js")
+ )
+);
+
+const {
+ changeNetworkThrottling,
+} = require("resource://devtools/client/shared/components/throttling/actions.js");
+const {
+ addCustomDevice,
+ editCustomDevice,
+ removeCustomDevice,
+ updateDeviceDisplayed,
+ updateDeviceModal,
+ updatePreferredDevices,
+} = require("resource://devtools/client/responsive/actions/devices.js");
+const {
+ takeScreenshot,
+} = require("resource://devtools/client/responsive/actions/screenshot.js");
+const {
+ changeUserAgent,
+ toggleLeftAlignment,
+ toggleReloadOnTouchSimulation,
+ toggleReloadOnUserAgent,
+ toggleTouchSimulation,
+ toggleUserAgentInput,
+} = require("resource://devtools/client/responsive/actions/ui.js");
+const {
+ changeDevice,
+ changePixelRatio,
+ changeViewportAngle,
+ removeDeviceAssociation,
+ resizeViewport,
+ rotateViewport,
+} = require("resource://devtools/client/responsive/actions/viewports.js");
+const {
+ getOrientation,
+} = require("resource://devtools/client/responsive/utils/orientation.js");
+
+const Types = require("resource://devtools/client/responsive/types.js");
+
+class App extends PureComponent {
+ static get propTypes() {
+ return {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ dispatch: PropTypes.func.isRequired,
+ leftAlignmentEnabled: PropTypes.bool.isRequired,
+ networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired,
+ screenshot: PropTypes.shape(Types.screenshot).isRequired,
+ viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.onAddCustomDevice = this.onAddCustomDevice.bind(this);
+ this.onBrowserContextMenu = this.onBrowserContextMenu.bind(this);
+ this.onChangeDevice = this.onChangeDevice.bind(this);
+ this.onChangeNetworkThrottling = this.onChangeNetworkThrottling.bind(this);
+ this.onChangePixelRatio = this.onChangePixelRatio.bind(this);
+ this.onChangeTouchSimulation = this.onChangeTouchSimulation.bind(this);
+ this.onChangeUserAgent = this.onChangeUserAgent.bind(this);
+ this.onChangeViewportOrientation =
+ this.onChangeViewportOrientation.bind(this);
+ this.onDeviceListUpdate = this.onDeviceListUpdate.bind(this);
+ this.onEditCustomDevice = this.onEditCustomDevice.bind(this);
+ this.onExit = this.onExit.bind(this);
+ this.onRemoveCustomDevice = this.onRemoveCustomDevice.bind(this);
+ this.onRemoveDeviceAssociation = this.onRemoveDeviceAssociation.bind(this);
+ this.doResizeViewport = this.doResizeViewport.bind(this);
+ this.onRotateViewport = this.onRotateViewport.bind(this);
+ this.onScreenshot = this.onScreenshot.bind(this);
+ this.onToggleLeftAlignment = this.onToggleLeftAlignment.bind(this);
+ this.onToggleReloadOnTouchSimulation =
+ this.onToggleReloadOnTouchSimulation.bind(this);
+ this.onToggleReloadOnUserAgent = this.onToggleReloadOnUserAgent.bind(this);
+ this.onToggleUserAgentInput = this.onToggleUserAgentInput.bind(this);
+ this.onUpdateDeviceDisplayed = this.onUpdateDeviceDisplayed.bind(this);
+ this.onUpdateDeviceModal = this.onUpdateDeviceModal.bind(this);
+ }
+
+ componentWillUnmount() {
+ this.browser.removeEventListener("contextmenu", this.onContextMenu);
+ this.browser = null;
+ }
+
+ onAddCustomDevice(device) {
+ this.props.dispatch(addCustomDevice(device));
+ }
+
+ onBrowserContextMenu() {
+ // Update the position of remote browser so that makes the context menu to show at
+ // proper position before showing.
+ this.browser.frameLoader.requestUpdatePosition();
+ }
+
+ onChangeDevice(id, device, deviceType) {
+ // Resize the viewport first.
+ this.doResizeViewport(id, device.width, device.height);
+
+ // TODO: Bug 1332754: Move messaging and logic into the action creator so that the
+ // message is sent from the action creator and device property changes are sent from
+ // there instead of this function.
+ window.postMessage(
+ {
+ type: "change-device",
+ device,
+ viewport: device,
+ },
+ "*"
+ );
+
+ const orientation = getOrientation(device, device);
+
+ this.props.dispatch(changeViewportAngle(0, orientation.angle));
+ this.props.dispatch(changeDevice(id, device.name, deviceType));
+ this.props.dispatch(changePixelRatio(id, device.pixelRatio));
+ this.props.dispatch(changeUserAgent(device.userAgent));
+ this.props.dispatch(toggleTouchSimulation(device.touch));
+ }
+
+ onChangeNetworkThrottling(enabled, profile) {
+ window.postMessage(
+ {
+ type: "change-network-throttling",
+ enabled,
+ profile,
+ },
+ "*"
+ );
+ this.props.dispatch(changeNetworkThrottling(enabled, profile));
+ }
+
+ onChangePixelRatio(pixelRatio) {
+ window.postMessage(
+ {
+ type: "change-pixel-ratio",
+ pixelRatio,
+ },
+ "*"
+ );
+ this.props.dispatch(changePixelRatio(0, pixelRatio));
+ }
+
+ onChangeTouchSimulation(enabled) {
+ window.postMessage(
+ {
+ type: "change-touch-simulation",
+ enabled,
+ },
+ "*"
+ );
+ this.props.dispatch(toggleTouchSimulation(enabled));
+ }
+
+ onChangeUserAgent(userAgent) {
+ window.postMessage(
+ {
+ type: "change-user-agent",
+ userAgent,
+ },
+ "*"
+ );
+ this.props.dispatch(changeUserAgent(userAgent));
+ }
+
+ onChangeViewportOrientation(id, type, angle, isViewportRotated = false) {
+ window.postMessage(
+ {
+ type: "viewport-orientation-change",
+ orientationType: type,
+ angle,
+ isViewportRotated,
+ },
+ "*"
+ );
+
+ if (isViewportRotated) {
+ this.props.dispatch(changeViewportAngle(id, angle));
+ }
+ }
+
+ onDeviceListUpdate(devices) {
+ updatePreferredDevices(devices);
+ }
+
+ onEditCustomDevice(oldDevice, newDevice) {
+ // If the edited device is currently selected, then update its original association
+ // and reset UI state.
+ let viewport = this.props.viewports.find(
+ ({ device }) => device === oldDevice.name
+ );
+
+ if (viewport) {
+ viewport = {
+ ...viewport,
+ device: newDevice.name,
+ deviceType: "custom",
+ height: newDevice.height,
+ width: newDevice.width,
+ pixelRatio: newDevice.pixelRatio,
+ touch: newDevice.touch,
+ userAgent: newDevice.userAgent,
+ };
+ }
+
+ this.props.dispatch(editCustomDevice(viewport, oldDevice, newDevice));
+ }
+
+ onExit() {
+ window.postMessage({ type: "exit" }, "*");
+ }
+
+ onRemoveCustomDevice(device) {
+ // If the custom device is currently selected on any of the viewports,
+ // remove the device association and reset all the ui state.
+ for (const viewport of this.props.viewports) {
+ if (viewport.device === device.name) {
+ this.onRemoveDeviceAssociation(viewport.id);
+ }
+ }
+
+ this.props.dispatch(removeCustomDevice(device));
+ }
+
+ onRemoveDeviceAssociation(id) {
+ // TODO: Bug 1332754: Move messaging and logic into the action creator so that device
+ // property changes are sent from there instead of this function.
+ this.props.dispatch(removeDeviceAssociation(id));
+ this.props.dispatch(toggleTouchSimulation(false));
+ this.props.dispatch(changePixelRatio(id, 0));
+ this.props.dispatch(changeUserAgent(""));
+ }
+
+ doResizeViewport(id, width, height) {
+ // This is the setter function that we pass to Toolbar and Viewports
+ // so they can modify the viewport.
+ window.postMessage(
+ {
+ type: "viewport-resize",
+ width,
+ height,
+ },
+ "*"
+ );
+ this.props.dispatch(resizeViewport(id, width, height));
+ }
+
+ /**
+ * Dispatches the rotateViewport action creator. This utilized by the RDM toolbar as
+ * a prop.
+ *
+ * @param {Number} id
+ * The viewport ID.
+ */
+ onRotateViewport(id) {
+ let currentDevice;
+ const viewport = this.props.viewports[id];
+
+ for (const type of this.props.devices.types) {
+ for (const device of this.props.devices[type]) {
+ if (viewport.device === device.name) {
+ currentDevice = device;
+ }
+ }
+ }
+
+ // If no device is selected, then assume the selected device's primary orientation is
+ // opposite of the viewport orientation.
+ if (!currentDevice) {
+ currentDevice = {
+ height: viewport.width,
+ width: viewport.height,
+ };
+ }
+
+ const currentAngle = Services.prefs.getIntPref(
+ "devtools.responsive.viewport.angle"
+ );
+ const angleToRotateTo = currentAngle === 90 ? 0 : 90;
+ const { type, angle } = getOrientation(
+ currentDevice,
+ viewport,
+ angleToRotateTo
+ );
+
+ this.onChangeViewportOrientation(id, type, angle, true);
+ this.props.dispatch(rotateViewport(id));
+
+ window.postMessage(
+ {
+ type: "viewport-resize",
+ height: viewport.width,
+ width: viewport.height,
+ },
+ "*"
+ );
+ }
+
+ onScreenshot() {
+ this.props.dispatch(takeScreenshot());
+ }
+
+ onToggleLeftAlignment() {
+ this.props.dispatch(toggleLeftAlignment());
+
+ window.postMessage(
+ {
+ type: "toggle-left-alignment",
+ leftAlignmentEnabled: this.props.leftAlignmentEnabled,
+ },
+ "*"
+ );
+ }
+
+ onToggleReloadOnTouchSimulation() {
+ this.props.dispatch(toggleReloadOnTouchSimulation());
+ }
+
+ onToggleReloadOnUserAgent() {
+ this.props.dispatch(toggleReloadOnUserAgent());
+ }
+
+ onToggleUserAgentInput() {
+ this.props.dispatch(toggleUserAgentInput());
+ }
+
+ onUpdateDeviceDisplayed(device, deviceType, displayed) {
+ this.props.dispatch(updateDeviceDisplayed(device, deviceType, displayed));
+ }
+
+ onUpdateDeviceModal(isOpen, modalOpenedFromViewport) {
+ this.props.dispatch(updateDeviceModal(isOpen, modalOpenedFromViewport));
+ window.postMessage({ type: "update-device-modal", isOpen }, "*");
+ }
+
+ render() {
+ const { devices, networkThrottling, screenshot, viewports } = this.props;
+
+ const {
+ onAddCustomDevice,
+ onChangeDevice,
+ onChangeNetworkThrottling,
+ onChangePixelRatio,
+ onChangeTouchSimulation,
+ onChangeUserAgent,
+ onDeviceListUpdate,
+ onEditCustomDevice,
+ onExit,
+ onRemoveCustomDevice,
+ onRemoveDeviceAssociation,
+ doResizeViewport,
+ onRotateViewport,
+ onScreenshot,
+ onToggleLeftAlignment,
+ onToggleReloadOnTouchSimulation,
+ onToggleReloadOnUserAgent,
+ onToggleUserAgentInput,
+ onUpdateDeviceDisplayed,
+ onUpdateDeviceModal,
+ } = this;
+
+ if (!viewports.length) {
+ return null;
+ }
+
+ const selectedDevice = viewports[0].device;
+ const selectedPixelRatio = viewports[0].pixelRatio;
+
+ let deviceAdderViewportTemplate = {};
+ if (devices.modalOpenedFromViewport !== null) {
+ deviceAdderViewportTemplate = viewports[devices.modalOpenedFromViewport];
+ }
+
+ return dom.div(
+ { id: "app" },
+ Toolbar({
+ devices,
+ networkThrottling,
+ screenshot,
+ selectedDevice,
+ selectedPixelRatio,
+ viewport: viewports[0],
+ onChangeDevice,
+ onChangeNetworkThrottling,
+ onChangePixelRatio,
+ onChangeTouchSimulation,
+ onChangeUserAgent,
+ onExit,
+ onRemoveDeviceAssociation,
+ doResizeViewport,
+ onRotateViewport,
+ onScreenshot,
+ onToggleLeftAlignment,
+ onToggleReloadOnTouchSimulation,
+ onToggleReloadOnUserAgent,
+ onToggleUserAgentInput,
+ onUpdateDeviceModal,
+ }),
+ devices.isModalOpen
+ ? DeviceModal({
+ deviceAdderViewportTemplate,
+ devices,
+ onAddCustomDevice,
+ onDeviceListUpdate,
+ onEditCustomDevice,
+ onRemoveCustomDevice,
+ onUpdateDeviceDisplayed,
+ onUpdateDeviceModal,
+ })
+ : null
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ ...state,
+ leftAlignmentEnabled: state.ui.leftAlignmentEnabled,
+ };
+};
+
+module.exports = connect(mapStateToProps)(App);
diff --git a/devtools/client/responsive/components/Device.js b/devtools/client/responsive/components/Device.js
new file mode 100644
index 0000000000..87c0845a70
--- /dev/null
+++ b/devtools/client/responsive/components/Device.js
@@ -0,0 +1,139 @@
+/* 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/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ getFormatStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+const {
+ parseUserAgent,
+} = require("resource://devtools/client/responsive/utils/ua.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+const DeviceInfo = createFactory(
+ require("resource://devtools/client/responsive/components/DeviceInfo.js")
+);
+
+class Device extends PureComponent {
+ static get propTypes() {
+ return {
+ // props.children are the buttons rendered as part of custom device label.
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node,
+ ]),
+ device: PropTypes.shape(Types.devices).isRequired,
+ onDeviceCheckboxChange: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ // Whether or not the device's input label is checked.
+ isChecked: this.props.device.isChecked,
+ };
+
+ this.onCheckboxChanged = this.onCheckboxChanged.bind(this);
+ }
+
+ onCheckboxChanged(e) {
+ this.setState(prevState => {
+ return { isChecked: !prevState.isChecked };
+ });
+
+ this.props.onDeviceCheckboxChange(e);
+ }
+
+ getBrowserOrOsTooltip(agent) {
+ if (agent) {
+ return agent.name + (agent.version ? ` ${agent.version}` : "");
+ }
+
+ return null;
+ }
+
+ getTooltip() {
+ const { device } = this.props;
+ const { browser, os } = parseUserAgent(device.userAgent);
+
+ const browserTitle = this.getBrowserOrOsTooltip(browser);
+ const osTitle = this.getBrowserOrOsTooltip(os);
+ let browserAndOsTitle = null;
+ if (browserTitle && osTitle) {
+ browserAndOsTitle = getFormatStr(
+ "responsive.deviceDetails.browserAndOS",
+ browserTitle,
+ osTitle
+ );
+ } else if (browserTitle || osTitle) {
+ browserAndOsTitle = browserTitle || osTitle;
+ }
+
+ const sizeTitle = getFormatStr(
+ "responsive.deviceDetails.size",
+ device.width,
+ device.height
+ );
+
+ const dprTitle = getFormatStr(
+ "responsive.deviceDetails.DPR",
+ device.pixelRatio
+ );
+
+ const uaTitle = getFormatStr(
+ "responsive.deviceDetails.UA",
+ device.userAgent
+ );
+
+ const touchTitle = getFormatStr(
+ "responsive.deviceDetails.touch",
+ device.touch
+ );
+
+ return (
+ `${browserAndOsTitle ? browserAndOsTitle + "\n" : ""}` +
+ `${sizeTitle}\n` +
+ `${dprTitle}\n` +
+ `${uaTitle}\n` +
+ `${touchTitle}\n`
+ );
+ }
+
+ render() {
+ const { children, device } = this.props;
+ const tooltip = this.getTooltip();
+
+ return dom.label(
+ {
+ className: "device-label",
+ key: device.name,
+ title: tooltip,
+ },
+ dom.input({
+ className: "device-input-checkbox",
+ name: device.name,
+ type: "checkbox",
+ value: device.name,
+ checked: device.isChecked,
+ onChange: this.onCheckboxChanged,
+ }),
+ DeviceInfo({ device }),
+ children
+ );
+ }
+}
+
+module.exports = Device;
diff --git a/devtools/client/responsive/components/DeviceAdder.js b/devtools/client/responsive/components/DeviceAdder.js
new file mode 100644
index 0000000000..b09da10f38
--- /dev/null
+++ b/devtools/client/responsive/components/DeviceAdder.js
@@ -0,0 +1,229 @@
+/* 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/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const ViewportDimension = createFactory(
+ require("resource://devtools/client/responsive/components/ViewportDimension.js")
+);
+
+const {
+ getFormatStr,
+ getStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+class DeviceAdder extends PureComponent {
+ static get propTypes() {
+ return {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ onAddCustomDevice: PropTypes.func.isRequired,
+ viewportTemplate: PropTypes.shape(Types.viewport).isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ const { height, width } = this.props.viewportTemplate;
+
+ this.state = {
+ deviceAdderDisplayed: false,
+ height,
+ width,
+ };
+
+ this.onChangeSize = this.onChangeSize.bind(this);
+ this.onDeviceAdderShow = this.onDeviceAdderShow.bind(this);
+ this.onDeviceAdderSave = this.onDeviceAdderSave.bind(this);
+ }
+
+ onChangeSize(_, width, height) {
+ this.setState({
+ width,
+ height,
+ });
+ }
+
+ onDeviceAdderShow() {
+ this.setState({
+ deviceAdderDisplayed: true,
+ });
+ }
+
+ onDeviceAdderSave() {
+ const { devices, onAddCustomDevice } = this.props;
+
+ if (!this.pixelRatioInput.checkValidity()) {
+ return;
+ }
+
+ if (devices.custom.find(device => device.name == this.nameInput.value)) {
+ this.nameInput.setCustomValidity("Device name already in use");
+ return;
+ }
+
+ this.setState({
+ deviceAdderDisplayed: false,
+ });
+
+ onAddCustomDevice({
+ name: this.nameInput.value,
+ width: this.state.width,
+ height: this.state.height,
+ pixelRatio: parseFloat(this.pixelRatioInput.value),
+ userAgent: this.userAgentInput.value,
+ touch: this.touchInput.checked,
+ });
+ }
+
+ render() {
+ const { devices, viewportTemplate } = this.props;
+
+ const { deviceAdderDisplayed, height, width } = this.state;
+
+ if (!deviceAdderDisplayed) {
+ return dom.div(
+ {
+ id: "device-adder",
+ },
+ dom.button(
+ {
+ id: "device-adder-show",
+ onClick: this.onDeviceAdderShow,
+ },
+ getStr("responsive.addDevice")
+ )
+ );
+ }
+
+ // If a device is currently selected, fold its attributes into a single object for use
+ // as the starting values of the form. If no device is selected, use the values for
+ // the current window.
+ let deviceName;
+ const normalizedViewport = Object.assign({}, viewportTemplate);
+ if (viewportTemplate.device) {
+ const device = devices[viewportTemplate.deviceType].find(d => {
+ return d.name == viewportTemplate.device;
+ });
+ deviceName = getFormatStr(
+ "responsive.customDeviceNameFromBase",
+ device.name
+ );
+ Object.assign(normalizedViewport, {
+ pixelRatio: device.pixelRatio,
+ userAgent: device.userAgent,
+ touch: device.touch,
+ });
+ } else {
+ deviceName = getStr("responsive.customDeviceName");
+ Object.assign(normalizedViewport, {
+ pixelRatio: window.devicePixelRatio,
+ userAgent: navigator.userAgent,
+ touch: false,
+ });
+ }
+
+ return dom.div(
+ { id: "device-adder" },
+ dom.div(
+ { id: "device-adder-content" },
+ dom.div(
+ { id: "device-adder-column-1" },
+ dom.label(
+ { id: "device-adder-name" },
+ dom.span(
+ { className: "device-adder-label" },
+ getStr("responsive.deviceAdderName")
+ ),
+ dom.input({
+ defaultValue: deviceName,
+ ref: input => {
+ this.nameInput = input;
+ },
+ })
+ ),
+ dom.label(
+ { id: "device-adder-size" },
+ dom.span(
+ { className: "device-adder-label" },
+ getStr("responsive.deviceAdderSize")
+ ),
+ ViewportDimension({
+ viewport: {
+ width,
+ height,
+ },
+ doResizeViewport: this.onChangeSize,
+ onRemoveDeviceAssociation: () => {},
+ })
+ ),
+ dom.label(
+ { id: "device-adder-pixel-ratio" },
+ dom.span(
+ { className: "device-adder-label" },
+ getStr("responsive.deviceAdderPixelRatio")
+ ),
+ dom.input({
+ type: "number",
+ step: "any",
+ defaultValue: normalizedViewport.pixelRatio,
+ ref: input => {
+ this.pixelRatioInput = input;
+ },
+ })
+ )
+ ),
+ dom.div(
+ { id: "device-adder-column-2" },
+ dom.label(
+ { id: "device-adder-user-agent" },
+ dom.span(
+ { className: "device-adder-label" },
+ getStr("responsive.deviceAdderUserAgent")
+ ),
+ dom.input({
+ defaultValue: normalizedViewport.userAgent,
+ ref: input => {
+ this.userAgentInput = input;
+ },
+ })
+ ),
+ dom.label(
+ { id: "device-adder-touch" },
+ dom.span(
+ { className: "device-adder-label" },
+ getStr("responsive.deviceAdderTouch")
+ ),
+ dom.input({
+ defaultChecked: normalizedViewport.touch,
+ type: "checkbox",
+ ref: input => {
+ this.touchInput = input;
+ },
+ })
+ )
+ )
+ ),
+ dom.button(
+ {
+ id: "device-adder-save",
+ onClick: this.onDeviceAdderSave,
+ },
+ getStr("responsive.deviceAdderSave")
+ )
+ );
+ }
+}
+
+module.exports = DeviceAdder;
diff --git a/devtools/client/responsive/components/DeviceForm.js b/devtools/client/responsive/components/DeviceForm.js
new file mode 100644
index 0000000000..656f846d33
--- /dev/null
+++ b/devtools/client/responsive/components/DeviceForm.js
@@ -0,0 +1,231 @@
+/* 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/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const {
+ createFactory,
+ createRef,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const ViewportDimension = createFactory(
+ require("resource://devtools/client/responsive/components/ViewportDimension.js")
+);
+
+const {
+ getStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+class DeviceForm extends PureComponent {
+ static get propTypes() {
+ return {
+ formType: PropTypes.string.isRequired,
+ device: PropTypes.shape(Types.device).isRequired,
+ devices: PropTypes.shape(Types.devices).isRequired,
+ onSave: PropTypes.func.isRequired,
+ viewportTemplate: PropTypes.shape(Types.viewport).isRequired,
+ onDeviceFormHide: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ const { height, width } = this.props.viewportTemplate;
+
+ this.state = {
+ height,
+ width,
+ };
+
+ this.nameInputRef = createRef();
+ this.pixelRatioInputRef = createRef();
+ this.touchInputRef = createRef();
+ this.userAgentInputRef = createRef();
+
+ this.onChangeSize = this.onChangeSize.bind(this);
+ this.onDeviceFormHide = this.onDeviceFormHide.bind(this);
+ this.onDeviceFormSave = this.onDeviceFormSave.bind(this);
+ this.onInputFocus = this.onInputFocus.bind(this);
+ this.validateNameField = this.validateNameField.bind(this);
+ }
+
+ onChangeSize(_, width, height) {
+ this.setState({
+ width,
+ height,
+ });
+ }
+
+ onDeviceFormSave(e) {
+ e.preventDefault();
+
+ if (!this.pixelRatioInputRef.current.checkValidity()) {
+ return;
+ }
+
+ if (
+ !this.validateNameField(
+ this.nameInputRef.current.value,
+ this.props.device
+ )
+ ) {
+ this.nameInputRef.current.setCustomValidity(
+ getStr("responsive.deviceNameAlreadyInUse")
+ );
+ return;
+ }
+
+ this.props.onSave({
+ name: this.nameInputRef.current.value.trim(),
+ width: this.state.width,
+ height: this.state.height,
+ pixelRatio: parseFloat(this.pixelRatioInputRef.current.value),
+ userAgent: this.userAgentInputRef.current.value,
+ touch: this.touchInputRef.current.checked,
+ });
+
+ this.onDeviceFormHide();
+ }
+
+ onDeviceFormHide() {
+ // Ensure that we have onDeviceFormHide before calling it.
+ if (this.props.onDeviceFormHide) {
+ this.props.onDeviceFormHide();
+ }
+ }
+
+ onInputFocus(e) {
+ // If the formType is "add", select all text in input field when focused.
+ if (this.props.formType === "add") {
+ e.target.select();
+ }
+ }
+
+ /**
+ * Validates the name field's value.
+ *
+ * @param {String} value
+ * The input field value for the device name.
+ * @return {Boolean} true if device name is valid, false otherwise.
+ */
+ validateNameField(value) {
+ const nameFieldValue = value.trim();
+ let isValidDeviceName = false;
+
+ // If the formType is "add", then we just need to check if a custom device with that
+ // same name exists.
+ if (this.props.formType === "add") {
+ isValidDeviceName = !this.props.devices.custom.find(
+ device => device.name == nameFieldValue
+ );
+ } else {
+ // Otherwise, the formType is "edit". We'd have to find another device that
+ // already has the same name, but not itself, so:
+ // Find the index of the device being edited. Use this index value to distinguish
+ // between the device being edited from other devices in the list.
+ const deviceIndex = this.props.devices.custom.findIndex(({ name }) => {
+ return name === this.props.device.name;
+ });
+
+ isValidDeviceName = !this.props.devices.custom.find((d, index) => {
+ return d.name === nameFieldValue && index !== deviceIndex;
+ });
+ }
+
+ return isValidDeviceName;
+ }
+
+ render() {
+ const { device, formType } = this.props;
+ const { width, height } = this.state;
+
+ return dom.form(
+ { id: "device-form" },
+ dom.label(
+ { id: "device-form-name" },
+ dom.span(
+ { className: "device-form-label" },
+ getStr("responsive.deviceAdderName")
+ ),
+ dom.input({
+ defaultValue: device.name,
+ ref: this.nameInputRef,
+ onFocus: this.onInputFocus,
+ })
+ ),
+ dom.label(
+ { id: "device-form-size" },
+ dom.span(
+ { className: "device-form-label" },
+ getStr("responsive.deviceAdderSize")
+ ),
+ ViewportDimension({
+ viewport: { width, height },
+ doResizeViewport: this.onChangeSize,
+ onRemoveDeviceAssociation: () => {},
+ })
+ ),
+ dom.label(
+ { id: "device-form-pixel-ratio" },
+ dom.span(
+ { className: "device-form-label" },
+ getStr("responsive.deviceAdderPixelRatio2")
+ ),
+ dom.input({
+ type: "number",
+ step: "any",
+ defaultValue: device.pixelRatio,
+ ref: this.pixelRatioInputRef,
+ onFocus: this.onInputFocus,
+ })
+ ),
+ dom.label(
+ { id: "device-form-user-agent" },
+ dom.span(
+ { className: "device-form-label" },
+ getStr("responsive.deviceAdderUserAgent2")
+ ),
+ dom.input({
+ defaultValue: device.userAgent,
+ ref: this.userAgentInputRef,
+ onFocus: this.onInputFocus,
+ })
+ ),
+ dom.label(
+ { id: "device-form-touch" },
+ dom.input({
+ defaultChecked: device.touch,
+ type: "checkbox",
+ ref: this.touchInputRef,
+ }),
+ dom.span(
+ { className: "device-form-label" },
+ getStr("responsive.deviceAdderTouch2")
+ )
+ ),
+ dom.div(
+ { className: "device-form-buttons" },
+ dom.button(
+ { id: "device-form-save", onClick: this.onDeviceFormSave },
+ formType === "add"
+ ? getStr("responsive.deviceAdderSave")
+ : getStr("responsive.deviceFormUpdate")
+ ),
+ dom.button(
+ { id: "device-form-cancel", onClick: this.onDeviceFormHide },
+ getStr("responsive.deviceAdderCancel")
+ )
+ )
+ );
+ }
+}
+
+module.exports = DeviceForm;
diff --git a/devtools/client/responsive/components/DeviceInfo.js b/devtools/client/responsive/components/DeviceInfo.js
new file mode 100644
index 0000000000..2dc53bb603
--- /dev/null
+++ b/devtools/client/responsive/components/DeviceInfo.js
@@ -0,0 +1,52 @@
+/* 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 {
+ createElement,
+ Fragment,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ parseUserAgent,
+} = require("resource://devtools/client/responsive/utils/ua.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+class DeviceInfo extends PureComponent {
+ static get propTypes() {
+ return {
+ device: PropTypes.shape(Types.devices).isRequired,
+ };
+ }
+
+ renderBrowser({ name }) {
+ return dom.span({
+ className: `device-browser ${name.toLowerCase()}`,
+ });
+ }
+
+ renderOS({ name, version }) {
+ const text = version ? `${name} ${version}` : name;
+ return dom.span({ className: "device-os" }, text);
+ }
+
+ render() {
+ const { device } = this.props;
+ const { browser, os } = parseUserAgent(device.userAgent);
+
+ return createElement(
+ Fragment,
+ null,
+ browser ? this.renderBrowser(browser) : dom.span(),
+ dom.span({ className: "device-name" }, device.name),
+ os ? this.renderOS(os) : dom.span()
+ );
+ }
+}
+
+module.exports = DeviceInfo;
diff --git a/devtools/client/responsive/components/DeviceList.js b/devtools/client/responsive/components/DeviceList.js
new file mode 100644
index 0000000000..3cf44f0e2e
--- /dev/null
+++ b/devtools/client/responsive/components/DeviceList.js
@@ -0,0 +1,97 @@
+/* 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/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const Types = require("resource://devtools/client/responsive/types.js");
+
+const Device = createFactory(
+ require("resource://devtools/client/responsive/components/Device.js")
+);
+
+class DeviceList extends PureComponent {
+ static get propTypes() {
+ return {
+ // Whether or not to show the custom device edit/remove buttons.
+ devices: PropTypes.shape(Types.devices).isRequired,
+ isDeviceFormShown: PropTypes.bool.isRequired,
+ onDeviceCheckboxChange: PropTypes.func.isRequired,
+ onDeviceFormHide: PropTypes.func.isRequired,
+ onDeviceFormShow: PropTypes.func.isRequired,
+ onRemoveCustomDevice: PropTypes.func.isRequired,
+ type: PropTypes.string.isRequired,
+ };
+ }
+
+ renderCustomDevice(device) {
+ const {
+ isDeviceFormShown,
+ type,
+ onDeviceCheckboxChange,
+ onRemoveCustomDevice,
+ } = this.props;
+
+ // Show a remove button for custom devices.
+ const removeDeviceButton = dom.button({
+ id: "device-edit-remove",
+ className: "device-remove-button devtools-button",
+ onClick: () => {
+ onRemoveCustomDevice(device);
+ this.props.onDeviceFormHide();
+ },
+ });
+
+ // Show an edit button for custom devices
+ const editButton = dom.button({
+ id: "device-edit-button",
+ className: "devtools-button",
+ onClick: () => {
+ this.props.onDeviceFormShow("edit", device);
+ },
+ });
+
+ return Device(
+ {
+ device,
+ key: device.name,
+ type,
+ onDeviceCheckboxChange,
+ },
+ // Don't show the remove/edit buttons for a custom device if the form is open.
+ !isDeviceFormShown ? editButton : null,
+ !isDeviceFormShown ? removeDeviceButton : null
+ );
+ }
+
+ render() {
+ const { devices, type, onDeviceCheckboxChange } = this.props;
+
+ return dom.div(
+ { className: "device-list" },
+ devices[type].map(device => {
+ if (type === "custom") {
+ return this.renderCustomDevice(device);
+ }
+
+ return Device({
+ device,
+ key: device.name,
+ type,
+ onDeviceCheckboxChange,
+ });
+ })
+ );
+ }
+}
+
+module.exports = DeviceList;
diff --git a/devtools/client/responsive/components/DeviceModal.js b/devtools/client/responsive/components/DeviceModal.js
new file mode 100644
index 0000000000..1cd21951e3
--- /dev/null
+++ b/devtools/client/responsive/components/DeviceModal.js
@@ -0,0 +1,303 @@
+/* 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/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const DeviceForm = createFactory(
+ require("resource://devtools/client/responsive/components/DeviceForm.js")
+);
+const DeviceList = createFactory(
+ require("resource://devtools/client/responsive/components/DeviceList.js")
+);
+
+const {
+ getFormatStr,
+ getStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+const {
+ getDeviceString,
+} = require("resource://devtools/client/shared/devices.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+class DeviceModal extends PureComponent {
+ static get propTypes() {
+ return {
+ deviceAdderViewportTemplate: PropTypes.shape(Types.viewport).isRequired,
+ devices: PropTypes.shape(Types.devices).isRequired,
+ onAddCustomDevice: PropTypes.func.isRequired,
+ onDeviceListUpdate: PropTypes.func.isRequired,
+ onEditCustomDevice: PropTypes.func.isRequired,
+ onRemoveCustomDevice: PropTypes.func.isRequired,
+ onUpdateDeviceDisplayed: PropTypes.func.isRequired,
+ onUpdateDeviceModal: PropTypes.func.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ // The device form type can be 3 states: "add", "edit", or "".
+ // "add" - The form shown is adding a new device.
+ // "edit" - The form shown is editing an existing custom device.
+ // "" - The form is closed.
+ deviceFormType: "",
+ // The device being edited from the edit form.
+ editingDevice: null,
+ };
+ for (const type of this.props.devices.types) {
+ for (const device of this.props.devices[type]) {
+ this.state[device.name] = device.displayed;
+ }
+ }
+
+ this.onAddCustomDevice = this.onAddCustomDevice.bind(this);
+ this.onDeviceCheckboxChange = this.onDeviceCheckboxChange.bind(this);
+ this.onDeviceFormShow = this.onDeviceFormShow.bind(this);
+ this.onDeviceFormHide = this.onDeviceFormHide.bind(this);
+ this.onDeviceModalSubmit = this.onDeviceModalSubmit.bind(this);
+ this.onEditCustomDevice = this.onEditCustomDevice.bind(this);
+ this.onKeyDown = this.onKeyDown.bind(this);
+ }
+
+ componentDidMount() {
+ window.addEventListener("keydown", this.onKeyDown, true);
+ }
+
+ componentWillUnmount() {
+ this.onDeviceModalSubmit();
+ window.removeEventListener("keydown", this.onKeyDown, true);
+ }
+
+ onAddCustomDevice(device) {
+ this.props.onAddCustomDevice(device);
+ // Default custom devices to enabled
+ this.setState({
+ [device.name]: true,
+ });
+ }
+
+ onDeviceCheckboxChange({ nativeEvent: { button }, target }) {
+ if (button !== 0) {
+ return;
+ }
+ this.setState({
+ [target.value]: !this.state[target.value],
+ });
+ }
+
+ onDeviceFormShow(type, device) {
+ this.setState({
+ deviceFormType: type,
+ editingDevice: device,
+ });
+ }
+
+ onDeviceFormHide() {
+ this.setState({
+ deviceFormType: "",
+ editingDevice: null,
+ });
+ }
+
+ onDeviceModalSubmit() {
+ const { devices, onDeviceListUpdate, onUpdateDeviceDisplayed } = this.props;
+
+ const preferredDevices = {
+ added: new Set(),
+ removed: new Set(),
+ };
+
+ for (const type of devices.types) {
+ for (const device of devices[type]) {
+ const newState = this.state[device.name];
+
+ if (device.featured && !newState) {
+ preferredDevices.removed.add(device.name);
+ } else if (!device.featured && newState) {
+ preferredDevices.added.add(device.name);
+ }
+
+ if (this.state[device.name] != device.displayed) {
+ onUpdateDeviceDisplayed(device, type, this.state[device.name]);
+ }
+ }
+ }
+
+ onDeviceListUpdate(preferredDevices);
+ }
+
+ onEditCustomDevice(newDevice) {
+ this.props.onEditCustomDevice(this.state.editingDevice, newDevice);
+
+ // We want to remove the original device name from state after editing, so create a
+ // new state setting the old key to null and the new one to true.
+ this.setState({
+ [this.state.editingDevice.name]: null,
+ [newDevice.name]: true,
+ });
+ }
+
+ onKeyDown(event) {
+ if (!this.props.devices.isModalOpen) {
+ return;
+ }
+ // Escape keycode
+ if (event.keyCode === 27) {
+ const { onUpdateDeviceModal } = this.props;
+ onUpdateDeviceModal(false);
+ }
+ }
+
+ renderAddForm() {
+ // If a device is currently selected, fold its attributes into a single object for use
+ // as the starting values of the form. If no device is selected, use the values for
+ // the current window.
+ const { deviceAdderViewportTemplate: viewportTemplate } = this.props;
+ const deviceTemplate = this.props.deviceAdderViewportTemplate;
+ if (viewportTemplate.device) {
+ const device = this.props.devices[viewportTemplate.deviceType].find(d => {
+ return d.name == viewportTemplate.device;
+ });
+ Object.assign(deviceTemplate, {
+ pixelRatio: device.pixelRatio,
+ userAgent: device.userAgent,
+ touch: device.touch,
+ name: getFormatStr("responsive.customDeviceNameFromBase", device.name),
+ });
+ } else {
+ Object.assign(deviceTemplate, {
+ pixelRatio: window.devicePixelRatio,
+ userAgent: navigator.userAgent,
+ touch: false,
+ name: getStr("responsive.customDeviceName"),
+ });
+ }
+
+ return DeviceForm({
+ formType: "add",
+ device: deviceTemplate,
+ devices: this.props.devices,
+ onDeviceFormHide: this.onDeviceFormHide,
+ onSave: this.onAddCustomDevice,
+ viewportTemplate,
+ });
+ }
+
+ renderDevices() {
+ const sortedDevices = {};
+ for (const type of this.props.devices.types) {
+ sortedDevices[type] = this.props.devices[type].sort((a, b) =>
+ a.name.localeCompare(b.name)
+ );
+
+ sortedDevices[type].forEach(device => {
+ device.isChecked = this.state[device.name];
+ });
+ }
+
+ return this.props.devices.types.map(type => {
+ return sortedDevices[type].length
+ ? dom.div(
+ {
+ className: `device-type device-type-${type}`,
+ key: type,
+ },
+ dom.header({ className: "device-header" }, getDeviceString(type)),
+ DeviceList({
+ devices: sortedDevices,
+ isDeviceFormShown: this.state.deviceFormType,
+ type,
+ onDeviceCheckboxChange: this.onDeviceCheckboxChange,
+ onDeviceFormHide: this.onDeviceFormHide,
+ onDeviceFormShow: this.onDeviceFormShow,
+ onEditCustomDevice: this.onEditCustomDevice,
+ onRemoveCustomDevice: this.props.onRemoveCustomDevice,
+ })
+ )
+ : null;
+ });
+ }
+
+ renderEditForm() {
+ return DeviceForm({
+ formType: "edit",
+ device: this.state.editingDevice,
+ devices: this.props.devices,
+ onDeviceFormHide: this.onDeviceFormHide,
+ onSave: this.onEditCustomDevice,
+ viewportTemplate: {
+ width: this.state.editingDevice.width,
+ height: this.state.editingDevice.height,
+ },
+ });
+ }
+
+ renderForm() {
+ let form = null;
+
+ if (this.state.deviceFormType === "add") {
+ form = this.renderAddForm();
+ } else if (this.state.deviceFormType === "edit") {
+ form = this.renderEditForm();
+ }
+
+ return form;
+ }
+
+ render() {
+ const { onUpdateDeviceModal } = this.props;
+ const isDeviceFormShown = this.state.deviceFormType;
+
+ return dom.div(
+ {
+ id: "device-modal-wrapper",
+ className: this.props.devices.isModalOpen ? "opened" : "closed",
+ },
+ dom.div(
+ { className: "device-modal" },
+ dom.div(
+ { className: "device-modal-header" },
+ !isDeviceFormShown
+ ? dom.header(
+ { className: "device-modal-title" },
+ getStr("responsive.deviceSettings"),
+ dom.button({
+ id: "device-close-button",
+ className: "devtools-button",
+ onClick: () => onUpdateDeviceModal(false),
+ })
+ )
+ : null,
+ !isDeviceFormShown
+ ? dom.button(
+ {
+ id: "device-add-button",
+ onClick: () => this.onDeviceFormShow("add"),
+ },
+ getStr("responsive.addDevice2")
+ )
+ : null,
+ this.renderForm()
+ ),
+ dom.div({ className: "device-modal-content" }, this.renderDevices())
+ ),
+ dom.div({
+ className: "modal-overlay",
+ onClick: () => onUpdateDeviceModal(false),
+ })
+ );
+ }
+}
+
+module.exports = DeviceModal;
diff --git a/devtools/client/responsive/components/DevicePixelRatioMenu.js b/devtools/client/responsive/components/DevicePixelRatioMenu.js
new file mode 100644
index 0000000000..7ad37b8e03
--- /dev/null
+++ b/devtools/client/responsive/components/DevicePixelRatioMenu.js
@@ -0,0 +1,100 @@
+/* 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/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ getStr,
+ getFormatStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+loader.lazyRequireGetter(
+ this,
+ "showMenu",
+ "resource://devtools/client/shared/components/menu/utils.js",
+ true
+);
+
+const PIXEL_RATIO_PRESET = [1, 2, 3];
+
+class DevicePixelRatioMenu extends PureComponent {
+ static get propTypes() {
+ return {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ displayPixelRatio: PropTypes.number.isRequired,
+ onChangePixelRatio: PropTypes.func.isRequired,
+ selectedDevice: PropTypes.string.isRequired,
+ selectedPixelRatio: PropTypes.number.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.onShowDevicePixelMenu = this.onShowDevicePixelMenu.bind(this);
+ }
+
+ onShowDevicePixelMenu(event) {
+ const { displayPixelRatio, onChangePixelRatio, selectedPixelRatio } =
+ this.props;
+
+ const menuItems = PIXEL_RATIO_PRESET.map(value => {
+ return {
+ label: getFormatStr("responsive.devicePixelRatioOption", value),
+ type: "checkbox",
+ checked:
+ selectedPixelRatio > 0
+ ? selectedPixelRatio === value
+ : displayPixelRatio === value,
+ click: () => onChangePixelRatio(+value),
+ };
+ });
+
+ showMenu(menuItems, {
+ button: event.target,
+ });
+ }
+
+ render() {
+ const { devices, displayPixelRatio, selectedDevice, selectedPixelRatio } =
+ this.props;
+
+ const isDisabled =
+ devices.listState !== Types.loadableState.LOADED || selectedDevice !== "";
+
+ let title;
+ if (isDisabled) {
+ title = getFormatStr("responsive.devicePixelRatio.auto", selectedDevice);
+ } else {
+ title = getStr("responsive.changeDevicePixelRatio");
+ }
+
+ return dom.button(
+ {
+ id: "device-pixel-ratio-menu",
+ className: "devtools-button devtools-dropdown-button",
+ disabled: isDisabled,
+ title,
+ onClick: this.onShowDevicePixelMenu,
+ },
+ dom.span(
+ { className: "title" },
+ getFormatStr(
+ "responsive.devicePixelRatioOption",
+ selectedPixelRatio || displayPixelRatio
+ )
+ )
+ );
+ }
+}
+
+module.exports = DevicePixelRatioMenu;
diff --git a/devtools/client/responsive/components/DeviceSelector.js b/devtools/client/responsive/components/DeviceSelector.js
new file mode 100644
index 0000000000..3028f2fa42
--- /dev/null
+++ b/devtools/client/responsive/components/DeviceSelector.js
@@ -0,0 +1,173 @@
+/* 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 {
+ createFactory,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const { hr } = dom;
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ getStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+const {
+ parseUserAgent,
+} = require("resource://devtools/client/responsive/utils/ua.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+const MenuButton = createFactory(
+ require("resource://devtools/client/shared/components/menu/MenuButton.js")
+);
+
+loader.lazyGetter(this, "MenuItem", () => {
+ const menuItemClass = require("resource://devtools/client/shared/components/menu/MenuItem.js");
+ const menuItem = createFactory(menuItemClass);
+ menuItem.DUMMY_ICON = menuItemClass.DUMMY_ICON;
+ return menuItem;
+});
+
+loader.lazyGetter(this, "MenuList", () => {
+ return createFactory(
+ require("resource://devtools/client/shared/components/menu/MenuList.js")
+ );
+});
+
+class DeviceSelector extends PureComponent {
+ static get propTypes() {
+ return {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ onChangeDevice: PropTypes.func.isRequired,
+ onUpdateDeviceModal: PropTypes.func.isRequired,
+ selectedDevice: PropTypes.string.isRequired,
+ viewportId: PropTypes.number.isRequired,
+ };
+ }
+
+ getMenuProps(device) {
+ if (!device) {
+ return { icon: null, label: null, tooltip: null };
+ }
+
+ const { browser, os } = parseUserAgent(device.userAgent);
+ let label = device.name;
+ if (os) {
+ label += ` ${os.name}`;
+ if (os.version) {
+ label += ` ${os.version}`;
+ }
+ }
+
+ let icon = null;
+ let tooltip = label;
+ if (browser) {
+ icon = `chrome://devtools/skin/images/browsers/${browser.name.toLowerCase()}.svg`;
+ tooltip += ` ${browser.name} ${browser.version}`;
+ }
+
+ return { icon, label, tooltip };
+ }
+
+ getSelectedDevice() {
+ const { devices, selectedDevice } = this.props;
+
+ if (!selectedDevice) {
+ return null;
+ }
+
+ for (const type of devices.types) {
+ for (const device of devices[type]) {
+ if (selectedDevice === device.name) {
+ return device;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ renderMenuList() {
+ const {
+ devices,
+ onChangeDevice,
+ onUpdateDeviceModal,
+ selectedDevice,
+ viewportId,
+ } = this.props;
+
+ const menuItems = [];
+
+ for (const type of devices.types) {
+ for (const device of devices[type]) {
+ if (device.displayed) {
+ const { icon, label, tooltip } = this.getMenuProps(device);
+
+ menuItems.push(
+ MenuItem({
+ key: label,
+ className: "device-selector-item",
+ checked: selectedDevice === device.name,
+ label,
+ icon: icon || MenuItem.DUMMY_ICON,
+ tooltip,
+ onClick: () => onChangeDevice(viewportId, device, type),
+ })
+ );
+ }
+ }
+ }
+
+ menuItems.sort(function (a, b) {
+ return a.props.label.localeCompare(b.props.label);
+ });
+
+ if (menuItems.length) {
+ menuItems.push(hr({ key: "separator" }));
+ }
+
+ menuItems.push(
+ MenuItem({
+ key: "edit-device",
+ label: getStr("responsive.editDeviceList2"),
+ onClick: () => onUpdateDeviceModal(true, viewportId),
+ })
+ );
+
+ return MenuList({}, menuItems);
+ }
+
+ render() {
+ const { devices } = this.props;
+ const selectedDevice = this.getSelectedDevice();
+ let { icon, label, tooltip } = this.getMenuProps(selectedDevice);
+
+ if (!selectedDevice) {
+ label = getStr("responsive.responsiveMode");
+ }
+
+ // MenuButton is expected to be used in the toolbox document usually,
+ // but since RDM's frame also loads theme-switching.js, we can create
+ // MenuButtons (& HTMLTooltips) in the RDM frame document.
+ const toolboxDoc = window.document;
+
+ return MenuButton(
+ {
+ id: "device-selector",
+ menuId: "device-selector-menu",
+ toolboxDoc,
+ className: "devtools-button devtools-dropdown-button",
+ label,
+ icon,
+ title: tooltip,
+ disabled: devices.listState !== Types.loadableState.LOADED,
+ },
+ () => this.renderMenuList()
+ );
+ }
+}
+
+module.exports = DeviceSelector;
diff --git a/devtools/client/responsive/components/SettingsMenu.js b/devtools/client/responsive/components/SettingsMenu.js
new file mode 100644
index 0000000000..a221ce39ec
--- /dev/null
+++ b/devtools/client/responsive/components/SettingsMenu.js
@@ -0,0 +1,122 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const {
+ getStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+
+loader.lazyRequireGetter(
+ this,
+ "showMenu",
+ "resource://devtools/client/shared/components/menu/utils.js",
+ true
+);
+
+class SettingsMenu extends PureComponent {
+ static get propTypes() {
+ return {
+ leftAlignmentEnabled: PropTypes.bool.isRequired,
+ onToggleLeftAlignment: PropTypes.func.isRequired,
+ onToggleReloadOnTouchSimulation: PropTypes.func.isRequired,
+ onToggleReloadOnUserAgent: PropTypes.func.isRequired,
+ onToggleUserAgentInput: PropTypes.func.isRequired,
+ reloadOnTouchSimulation: PropTypes.bool.isRequired,
+ reloadOnUserAgent: PropTypes.bool.isRequired,
+ showUserAgentInput: PropTypes.bool.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.onToggleSettingMenu = this.onToggleSettingMenu.bind(this);
+ }
+
+ onToggleSettingMenu(event) {
+ const {
+ leftAlignmentEnabled,
+ onToggleLeftAlignment,
+ onToggleReloadOnTouchSimulation,
+ onToggleReloadOnUserAgent,
+ onToggleUserAgentInput,
+ reloadOnTouchSimulation,
+ reloadOnUserAgent,
+ showUserAgentInput,
+ } = this.props;
+
+ const menuItems = [
+ {
+ id: "toggleLeftAlignment",
+ checked: leftAlignmentEnabled,
+ label: getStr("responsive.leftAlignViewport"),
+ type: "checkbox",
+ click: () => {
+ onToggleLeftAlignment();
+ },
+ },
+ "-",
+ {
+ id: "toggleUserAgentInput",
+ checked: showUserAgentInput,
+ label: getStr("responsive.showUserAgentInput"),
+ type: "checkbox",
+ click: () => {
+ onToggleUserAgentInput();
+ },
+ },
+ "-",
+ {
+ id: "touchSimulation",
+ checked: reloadOnTouchSimulation,
+ label: getStr("responsive.reloadConditions.touchSimulation"),
+ type: "checkbox",
+ click: () => {
+ onToggleReloadOnTouchSimulation();
+ },
+ },
+ {
+ id: "userAgent",
+ checked: reloadOnUserAgent,
+ label: getStr("responsive.reloadConditions.userAgent"),
+ type: "checkbox",
+ click: () => {
+ onToggleReloadOnUserAgent();
+ },
+ },
+ ];
+
+ showMenu(menuItems, {
+ button: event.target,
+ });
+ }
+
+ render() {
+ return dom.button({
+ id: "settings-button",
+ className: "devtools-button",
+ onClick: this.onToggleSettingMenu,
+ });
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ leftAlignmentEnabled: state.ui.leftAlignmentEnabled,
+ reloadOnTouchSimulation: state.ui.reloadOnTouchSimulation,
+ reloadOnUserAgent: state.ui.reloadOnUserAgent,
+ showUserAgentInput: state.ui.showUserAgentInput,
+ };
+};
+
+module.exports = connect(mapStateToProps)(SettingsMenu);
diff --git a/devtools/client/responsive/components/Toolbar.js b/devtools/client/responsive/components/Toolbar.js
new file mode 100644
index 0000000000..a9a75dc9b3
--- /dev/null
+++ b/devtools/client/responsive/components/Toolbar.js
@@ -0,0 +1,216 @@
+/* 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 {
+ createElement,
+ createFactory,
+ Fragment,
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+
+const DevicePixelRatioMenu = createFactory(
+ require("resource://devtools/client/responsive/components/DevicePixelRatioMenu.js")
+);
+const DeviceSelector = createFactory(
+ require("resource://devtools/client/responsive/components/DeviceSelector.js")
+);
+const NetworkThrottlingMenu = createFactory(
+ require("resource://devtools/client/shared/components/throttling/NetworkThrottlingMenu.js")
+);
+const SettingsMenu = createFactory(
+ require("resource://devtools/client/responsive/components/SettingsMenu.js")
+);
+const ViewportDimension = createFactory(
+ require("resource://devtools/client/responsive/components/ViewportDimension.js")
+);
+
+loader.lazyGetter(this, "UserAgentInput", function () {
+ return createFactory(
+ require("resource://devtools/client/responsive/components/UserAgentInput.js")
+ );
+});
+
+const {
+ getStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+class Toolbar extends PureComponent {
+ static get propTypes() {
+ return {
+ devices: PropTypes.shape(Types.devices).isRequired,
+ displayPixelRatio: PropTypes.number.isRequired,
+ leftAlignmentEnabled: PropTypes.bool.isRequired,
+ networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired,
+ onChangeDevice: PropTypes.func.isRequired,
+ onChangeNetworkThrottling: PropTypes.func.isRequired,
+ onChangePixelRatio: PropTypes.func.isRequired,
+ onChangeTouchSimulation: PropTypes.func.isRequired,
+ onChangeUserAgent: PropTypes.func.isRequired,
+ onExit: PropTypes.func.isRequired,
+ onRemoveDeviceAssociation: PropTypes.func.isRequired,
+ doResizeViewport: PropTypes.func.isRequired,
+ onRotateViewport: PropTypes.func.isRequired,
+ onScreenshot: PropTypes.func.isRequired,
+ onToggleLeftAlignment: PropTypes.func.isRequired,
+ onToggleReloadOnTouchSimulation: PropTypes.func.isRequired,
+ onToggleReloadOnUserAgent: PropTypes.func.isRequired,
+ onToggleUserAgentInput: PropTypes.func.isRequired,
+ onUpdateDeviceModal: PropTypes.func.isRequired,
+ screenshot: PropTypes.shape(Types.screenshot).isRequired,
+ selectedDevice: PropTypes.string.isRequired,
+ selectedPixelRatio: PropTypes.number.isRequired,
+ showUserAgentInput: PropTypes.bool.isRequired,
+ touchSimulationEnabled: PropTypes.bool.isRequired,
+ viewport: PropTypes.shape(Types.viewport).isRequired,
+ };
+ }
+
+ renderUserAgent() {
+ const { onChangeUserAgent, showUserAgentInput } = this.props;
+
+ if (!showUserAgentInput) {
+ return null;
+ }
+
+ return createElement(
+ Fragment,
+ null,
+ UserAgentInput({
+ onChangeUserAgent,
+ }),
+ dom.div({ className: "devtools-separator" })
+ );
+ }
+
+ render() {
+ const {
+ devices,
+ displayPixelRatio,
+ leftAlignmentEnabled,
+ networkThrottling,
+ onChangeDevice,
+ onChangeNetworkThrottling,
+ onChangePixelRatio,
+ onChangeTouchSimulation,
+ onExit,
+ onRemoveDeviceAssociation,
+ doResizeViewport,
+ onRotateViewport,
+ onScreenshot,
+ onToggleLeftAlignment,
+ onToggleReloadOnTouchSimulation,
+ onToggleReloadOnUserAgent,
+ onToggleUserAgentInput,
+ onUpdateDeviceModal,
+ screenshot,
+ selectedDevice,
+ selectedPixelRatio,
+ showUserAgentInput,
+ touchSimulationEnabled,
+ viewport,
+ } = this.props;
+
+ return dom.header(
+ {
+ id: "toolbar",
+ className: [
+ leftAlignmentEnabled ? "left-aligned" : "",
+ showUserAgentInput ? "user-agent" : "",
+ ]
+ .join(" ")
+ .trim(),
+ },
+ dom.div(
+ { id: "toolbar-center-controls" },
+ DeviceSelector({
+ devices,
+ onChangeDevice,
+ onUpdateDeviceModal,
+ selectedDevice,
+ viewportId: viewport.id,
+ }),
+ dom.div({ className: "devtools-separator" }),
+ ViewportDimension({
+ onRemoveDeviceAssociation,
+ doResizeViewport,
+ viewport,
+ }),
+ dom.button({
+ id: "rotate-button",
+ className: `devtools-button viewport-orientation-${
+ viewport.width > viewport.height ? "landscape" : "portrait"
+ }`,
+ onClick: () => onRotateViewport(viewport.id),
+ title: getStr("responsive.rotate"),
+ }),
+ dom.div({ className: "devtools-separator" }),
+ DevicePixelRatioMenu({
+ devices,
+ displayPixelRatio,
+ onChangePixelRatio,
+ selectedDevice,
+ selectedPixelRatio,
+ }),
+ dom.div({ className: "devtools-separator" }),
+ NetworkThrottlingMenu({
+ networkThrottling,
+ onChangeNetworkThrottling,
+ }),
+ dom.div({ className: "devtools-separator" }),
+ this.renderUserAgent(),
+ dom.button({
+ id: "touch-simulation-button",
+ className:
+ "devtools-button" + (touchSimulationEnabled ? " checked" : ""),
+ title: touchSimulationEnabled
+ ? getStr("responsive.disableTouch")
+ : getStr("responsive.enableTouch"),
+ onClick: () => onChangeTouchSimulation(!touchSimulationEnabled),
+ })
+ ),
+ dom.div(
+ { id: "toolbar-end-controls" },
+ dom.button({
+ id: "screenshot-button",
+ className: "devtools-button",
+ title: getStr("responsive.screenshot"),
+ onClick: onScreenshot,
+ disabled: screenshot.isCapturing,
+ }),
+ SettingsMenu({
+ onToggleLeftAlignment,
+ onToggleReloadOnTouchSimulation,
+ onToggleReloadOnUserAgent,
+ onToggleUserAgentInput,
+ }),
+ dom.div({ className: "devtools-separator" }),
+ dom.button({
+ id: "exit-button",
+ className: "devtools-button",
+ title: getStr("responsive.exit"),
+ onClick: onExit,
+ })
+ )
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ displayPixelRatio: state.ui.displayPixelRatio,
+ leftAlignmentEnabled: state.ui.leftAlignmentEnabled,
+ showUserAgentInput: state.ui.showUserAgentInput,
+ touchSimulationEnabled: state.ui.touchSimulationEnabled,
+ };
+};
+
+module.exports = connect(mapStateToProps)(Toolbar);
diff --git a/devtools/client/responsive/components/UserAgentInput.js b/devtools/client/responsive/components/UserAgentInput.js
new file mode 100644
index 0000000000..d24fc99c6f
--- /dev/null
+++ b/devtools/client/responsive/components/UserAgentInput.js
@@ -0,0 +1,103 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/vendor/react-redux.js");
+const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
+
+const {
+ getStr,
+} = require("resource://devtools/client/responsive/utils/l10n.js");
+
+class UserAgentInput extends PureComponent {
+ static get propTypes() {
+ return {
+ onChangeUserAgent: PropTypes.func.isRequired,
+ userAgent: PropTypes.string.isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ // The user agent input value.
+ value: this.props.userAgent,
+ };
+
+ this.onChange = this.onChange.bind(this);
+ this.onKeyUp = this.onKeyUp.bind(this);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ if (this.props.userAgent !== nextProps.userAgent) {
+ this.setState({ value: nextProps.userAgent });
+ }
+ }
+
+ /**
+ * Input change handler.
+ *
+ * @param {Event} event
+ */
+ onChange({ target }) {
+ const value = target.value;
+
+ this.setState(prevState => {
+ return {
+ ...prevState,
+ value,
+ };
+ });
+ }
+
+ /**
+ * Input key up handler.
+ *
+ * @param {Event} event
+ */
+ onKeyUp({ target, keyCode }) {
+ if (keyCode == KeyCodes.DOM_VK_RETURN) {
+ this.props.onChangeUserAgent(target.value);
+ target.blur();
+ }
+
+ if (keyCode == KeyCodes.DOM_VK_ESCAPE) {
+ target.blur();
+ }
+ }
+
+ render() {
+ return dom.label(
+ { id: "user-agent-label" },
+ "UA:",
+ dom.input({
+ id: "user-agent-input",
+ className: "text-input",
+ onChange: this.onChange,
+ onKeyUp: this.onKeyUp,
+ placeholder: getStr("responsive.customUserAgent"),
+ type: "text",
+ value: this.state.value,
+ })
+ );
+ }
+}
+
+const mapStateToProps = state => {
+ return {
+ userAgent: state.ui.userAgent,
+ };
+};
+
+module.exports = connect(mapStateToProps)(UserAgentInput);
diff --git a/devtools/client/responsive/components/ViewportDimension.js b/devtools/client/responsive/components/ViewportDimension.js
new file mode 100644
index 0000000000..988b690e21
--- /dev/null
+++ b/devtools/client/responsive/components/ViewportDimension.js
@@ -0,0 +1,251 @@
+/* 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 {
+ PureComponent,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+
+const {
+ isKeyIn,
+} = require("resource://devtools/client/responsive/utils/key.js");
+const {
+ MIN_VIEWPORT_DIMENSION,
+} = require("resource://devtools/client/responsive/constants.js");
+const Types = require("resource://devtools/client/responsive/types.js");
+
+class ViewportDimension extends PureComponent {
+ static get propTypes() {
+ return {
+ doResizeViewport: PropTypes.func.isRequired,
+ onRemoveDeviceAssociation: PropTypes.func.isRequired,
+ viewport: PropTypes.shape(Types.viewport).isRequired,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ const { width, height } = props.viewport;
+
+ this.state = {
+ width,
+ height,
+ isEditing: false,
+ isWidthValid: true,
+ isHeightValid: true,
+ };
+
+ this.isInputValid = this.isInputValid.bind(this);
+ this.onInputBlur = this.onInputBlur.bind(this);
+ this.onInputChange = this.onInputChange.bind(this);
+ this.onInputFocus = this.onInputFocus.bind(this);
+ this.onInputKeyDown = this.onInputKeyDown.bind(this);
+ this.onInputKeyUp = this.onInputKeyUp.bind(this);
+ this.onInputSubmit = this.onInputSubmit.bind(this);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { width, height } = nextProps.viewport;
+
+ this.setState({
+ width,
+ height,
+ });
+ }
+
+ /**
+ * Return true if the given value is a number and greater than MIN_VIEWPORT_DIMENSION
+ * and false otherwise.
+ */
+ isInputValid(value) {
+ return (
+ /^\d{2,4}$/.test(value) && parseInt(value, 10) >= MIN_VIEWPORT_DIMENSION
+ );
+ }
+
+ onInputBlur() {
+ const { width, height } = this.props.viewport;
+
+ if (this.state.width != width || this.state.height != height) {
+ this.onInputSubmit();
+ }
+
+ this.setState({ isEditing: false });
+ }
+
+ onInputChange({ target }, callback) {
+ if (target.value.length > 4) {
+ return;
+ }
+
+ if (this.widthInput == target) {
+ this.setState(
+ {
+ width: target.value,
+ isWidthValid: this.isInputValid(target.value),
+ },
+ callback
+ );
+ }
+
+ if (this.heightInput == target) {
+ this.setState(
+ {
+ height: target.value,
+ isHeightValid: this.isInputValid(target.value),
+ },
+ callback
+ );
+ }
+ }
+
+ onInputFocus(e) {
+ this.setState({ isEditing: true });
+ e.target.select();
+ }
+
+ onInputKeyDown(event) {
+ const increment = getIncrement(event);
+ if (!increment) {
+ return;
+ }
+
+ const { target } = event;
+ target.value = parseInt(target.value, 10) + increment;
+ this.onInputChange(event, this.onInputSubmit);
+
+ // Keep this event from having default processing. Since the field is a
+ // number field, default processing would trigger additional manipulations
+ // of the value, and we've already applied the desired amount.
+ event.preventDefault();
+ }
+
+ onInputKeyUp({ target, keyCode }) {
+ // On Enter, submit the input
+ if (keyCode == 13) {
+ this.onInputSubmit();
+ }
+
+ // On Esc, blur the target
+ if (keyCode == 27) {
+ target.blur();
+ }
+ }
+
+ onInputSubmit() {
+ const { viewport, onRemoveDeviceAssociation, doResizeViewport } =
+ this.props;
+
+ if (!this.state.isWidthValid || !this.state.isHeightValid) {
+ const { width, height } = viewport;
+
+ this.setState({
+ width,
+ height,
+ isWidthValid: true,
+ isHeightValid: true,
+ });
+
+ return;
+ }
+
+ // Change the device selector back to an unselected device
+ // TODO: Bug 1332754: Logic like this probably belongs in the action creator.
+ if (viewport.device) {
+ onRemoveDeviceAssociation(viewport.id);
+ }
+
+ doResizeViewport(
+ viewport.id,
+ parseInt(this.state.width, 10),
+ parseInt(this.state.height, 10)
+ );
+ }
+
+ render() {
+ return dom.div(
+ {
+ className:
+ "viewport-dimension" +
+ (this.state.isEditing ? " editing" : "") +
+ (!this.state.isWidthValid || !this.state.isHeightValid
+ ? " invalid"
+ : ""),
+ },
+ dom.input({
+ ref: input => {
+ this.widthInput = input;
+ },
+ className:
+ "text-input viewport-dimension-input" +
+ (this.state.isWidthValid ? "" : " invalid"),
+ size: 4,
+ type: "number",
+ value: this.state.width,
+ onBlur: this.onInputBlur,
+ onChange: this.onInputChange,
+ onFocus: this.onInputFocus,
+ onKeyDown: this.onInputKeyDown,
+ onKeyUp: this.onInputKeyUp,
+ }),
+ dom.span(
+ {
+ className: "viewport-dimension-separator",
+ },
+ "×"
+ ),
+ dom.input({
+ ref: input => {
+ this.heightInput = input;
+ },
+ className:
+ "text-input viewport-dimension-input" +
+ (this.state.isHeightValid ? "" : " invalid"),
+ size: 4,
+ type: "number",
+ value: this.state.height,
+ onBlur: this.onInputBlur,
+ onChange: this.onInputChange,
+ onFocus: this.onInputFocus,
+ onKeyDown: this.onInputKeyDown,
+ onKeyUp: this.onInputKeyUp,
+ })
+ );
+ }
+}
+
+/**
+ * Get the increment/decrement step to use for the provided key event.
+ */
+function getIncrement(event) {
+ const defaultIncrement = 1;
+ const largeIncrement = 100;
+ const mediumIncrement = 10;
+
+ let increment = 0;
+ const key = event.keyCode;
+
+ if (isKeyIn(key, "UP", "PAGE_UP")) {
+ increment = 1 * defaultIncrement;
+ } else if (isKeyIn(key, "DOWN", "PAGE_DOWN")) {
+ increment = -1 * defaultIncrement;
+ }
+
+ if (event.shiftKey) {
+ if (isKeyIn(key, "PAGE_UP", "PAGE_DOWN")) {
+ increment *= largeIncrement;
+ } else {
+ increment *= mediumIncrement;
+ }
+ }
+
+ return increment;
+}
+
+module.exports = ViewportDimension;
diff --git a/devtools/client/responsive/components/moz.build b/devtools/client/responsive/components/moz.build
new file mode 100644
index 0000000000..04ef642afc
--- /dev/null
+++ b/devtools/client/responsive/components/moz.build
@@ -0,0 +1,20 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "App.js",
+ "Device.js",
+ "DeviceForm.js",
+ "DeviceInfo.js",
+ "DeviceList.js",
+ "DeviceModal.js",
+ "DevicePixelRatioMenu.js",
+ "DeviceSelector.js",
+ "SettingsMenu.js",
+ "Toolbar.js",
+ "UserAgentInput.js",
+ "ViewportDimension.js",
+)