diff options
Diffstat (limited to 'devtools/client/responsive/components')
-rw-r--r-- | devtools/client/responsive/components/App.js | 447 | ||||
-rw-r--r-- | devtools/client/responsive/components/Device.js | 139 | ||||
-rw-r--r-- | devtools/client/responsive/components/DeviceAdder.js | 229 | ||||
-rw-r--r-- | devtools/client/responsive/components/DeviceForm.js | 231 | ||||
-rw-r--r-- | devtools/client/responsive/components/DeviceInfo.js | 52 | ||||
-rw-r--r-- | devtools/client/responsive/components/DeviceList.js | 97 | ||||
-rw-r--r-- | devtools/client/responsive/components/DeviceModal.js | 303 | ||||
-rw-r--r-- | devtools/client/responsive/components/DevicePixelRatioMenu.js | 100 | ||||
-rw-r--r-- | devtools/client/responsive/components/DeviceSelector.js | 173 | ||||
-rw-r--r-- | devtools/client/responsive/components/SettingsMenu.js | 122 | ||||
-rw-r--r-- | devtools/client/responsive/components/Toolbar.js | 216 | ||||
-rw-r--r-- | devtools/client/responsive/components/UserAgentInput.js | 103 | ||||
-rw-r--r-- | devtools/client/responsive/components/ViewportDimension.js | 251 | ||||
-rw-r--r-- | devtools/client/responsive/components/moz.build | 20 |
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", +) |