diff --git a/devtools/client/locales/en-US/responsive.properties b/devtools/client/locales/en-US/responsive.properties index 8406987eda77..fa5965828ed9 100644 --- a/devtools/client/locales/en-US/responsive.properties +++ b/devtools/client/locales/en-US/responsive.properties @@ -21,3 +21,12 @@ responsive.exit=Close Responsive Design Mode # LOCALIZATION NOTE (responsive.noDeviceSelected): placeholder text for the # device selector responsive.noDeviceSelected=no device selected + +# LOCALIZATION NOTE (responsive.screenshot): tooltip of the screenshot button. +responsive.screenshot=Take a screenshot of the viewport + +# LOCALIZATION NOTE (responsive.screenshotGeneratedFilename): The auto generated +# filename. +# The first argument (%1$S) is the date string in yyyy-mm-dd format and the +# second argument (%2$S) is the time string in HH.MM.SS format. +responsive.screenshotGeneratedFilename=Screen Shot %1$S at %2$S diff --git a/devtools/client/responsive.html/actions/index.js b/devtools/client/responsive.html/actions/index.js index de026ba7ba8e..8f5e30822c8c 100644 --- a/devtools/client/responsive.html/actions/index.js +++ b/devtools/client/responsive.html/actions/index.js @@ -32,6 +32,12 @@ createEnum([ // Rotate the viewport. "ROTATE_VIEWPORT", + // Take a screenshot of the viewport. + "TAKE_SCREENSHOT_START", + + // Indicates when the screenshot action ends. + "TAKE_SCREENSHOT_END", + ], module.exports); /** diff --git a/devtools/client/responsive.html/actions/moz.build b/devtools/client/responsive.html/actions/moz.build index b64117d96201..db36543d7651 100644 --- a/devtools/client/responsive.html/actions/moz.build +++ b/devtools/client/responsive.html/actions/moz.build @@ -8,5 +8,6 @@ DevToolsModules( 'devices.js', 'index.js', 'location.js', + 'screenshot.js', 'viewports.js', ) diff --git a/devtools/client/responsive.html/actions/screenshot.js b/devtools/client/responsive.html/actions/screenshot.js new file mode 100644 index 000000000000..c913c3227501 --- /dev/null +++ b/devtools/client/responsive.html/actions/screenshot.js @@ -0,0 +1,89 @@ +/* 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 HTML_NS = "http://www.w3.org/1999/xhtml"; + +const { + TAKE_SCREENSHOT_START, + TAKE_SCREENSHOT_END, +} = require("./index"); + +const { getRect } = require("devtools/shared/layout/utils"); +const { getFormatStr } = require("../utils/l10n"); +const { getToplevelWindow } = require("sdk/window/utils"); +const { Task: { spawn, async } } = require("resource://gre/modules/Task.jsm"); + +const BASE_URL = "resource://devtools/client/responsive.html"; +const audioCamera = new window.Audio(`${BASE_URL}/audio/camera-click.mp3`); + +function getFileName() { + let date = new Date(); + let month = ("0" + (date.getMonth() + 1)).substr(-2); + let day = ("0" + date.getDate()).substr(-2); + let dateString = [date.getFullYear(), month, day].join("-"); + let timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0]; + + return getFormatStr("responsive.screenshotGeneratedFilename", dateString, + timeString); +} + +function createScreenshotFor(node) { + let { top, left, width, height } = getRect(window, node, window); + + const canvas = document.createElementNS(HTML_NS, "canvas"); + const ctx = canvas.getContext("2d"); + const ratio = window.devicePixelRatio; + canvas.width = width * ratio; + canvas.height = height * ratio; + ctx.scale(ratio, ratio); + ctx.drawWindow(window, left, top, width, height, "#fff"); + + return canvas.toDataURL("image/png", ""); +} + +function saveToFile(data, filename) { + return spawn(function* () { + const chromeWindow = getToplevelWindow(window); + const chromeDocument = chromeWindow.document; + + // append .png extension to filename if it doesn't exist + filename = filename.replace(/\.png$|$/i, ".png"); + + chromeWindow.saveURL(data, filename, null, + true, true, + chromeDocument.documentURIObject, chromeDocument); + }); +} + +function simulateCameraEffects(node) { + audioCamera.play(); + node.animate({ opacity: [ 0, 1 ] }, 500); +} + +module.exports = { + + takeScreenshot() { + return function* (dispatch, getState) { + yield dispatch({ type: TAKE_SCREENSHOT_START }); + + // Waiting the next repaint, to ensure the react components + // can be properly render after the action dispatched above + window.requestAnimationFrame(async(function* () { + let iframe = document.querySelector("iframe"); + let data = createScreenshotFor(iframe); + + simulateCameraEffects(iframe); + + yield saveToFile(data, getFileName()); + + dispatch({ type: TAKE_SCREENSHOT_END }); + })); + }; + } + +}; diff --git a/devtools/client/responsive.html/app.js b/devtools/client/responsive.html/app.js index c0d9487b7926..c6a6f47cefc4 100644 --- a/devtools/client/responsive.html/app.js +++ b/devtools/client/responsive.html/app.js @@ -2,6 +2,8 @@ * 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 { createClass, createFactory, PropTypes, DOM: dom } = @@ -13,6 +15,7 @@ const { resizeViewport, rotateViewport } = require("./actions/viewports"); +const { takeScreenshot } = require("./actions/screenshot"); const Types = require("./types"); const Viewports = createFactory(require("./components/viewports")); const GlobalToolbar = createFactory(require("./components/global-toolbar")); @@ -25,13 +28,17 @@ let App = createClass({ devices: PropTypes.shape(Types.devices).isRequired, location: Types.location.isRequired, viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired, - onExit: PropTypes.func.isRequired, + screenshot: PropTypes.shape(Types.screenshot).isRequired, }, onChangeViewportDevice(id, device) { this.props.dispatch(changeDevice(id, device)); }, + onExit() { + window.postMessage({ type: "exit" }, "*"); + }, + onResizeViewport(id, width, height) { this.props.dispatch(resizeViewport(id, width, height)); }, @@ -40,18 +47,24 @@ let App = createClass({ this.props.dispatch(rotateViewport(id)); }, + onScreenshot() { + this.props.dispatch(takeScreenshot()); + }, + render() { let { devices, location, + screenshot, viewports, - onExit, } = this.props; let { onChangeViewportDevice, + onExit, onResizeViewport, onRotateViewport, + onScreenshot, } = this; return dom.div( @@ -59,11 +72,14 @@ let App = createClass({ id: "app", }, GlobalToolbar({ + screenshot, onExit, + onScreenshot, }), Viewports({ devices, location, + screenshot, viewports, onChangeViewportDevice, onRotateViewport, diff --git a/devtools/client/responsive.html/audio/camera-click.mp3 b/devtools/client/responsive.html/audio/camera-click.mp3 new file mode 100644 index 000000000000..6d9af013315d Binary files /dev/null and b/devtools/client/responsive.html/audio/camera-click.mp3 differ diff --git a/devtools/client/responsive.html/audio/moz.build b/devtools/client/responsive.html/audio/moz.build new file mode 100644 index 000000000000..b7b9bc8c7076 --- /dev/null +++ b/devtools/client/responsive.html/audio/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; c-basic-offset: 4; 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( + 'camera-click.mp3', +) diff --git a/devtools/client/responsive.html/components/device-selector.js b/devtools/client/responsive.html/components/device-selector.js index 3c35cc1f51a5..2b6053ffd46f 100644 --- a/devtools/client/responsive.html/components/device-selector.js +++ b/devtools/client/responsive.html/components/device-selector.js @@ -4,7 +4,7 @@ "use strict"; -const { getStr } = require("./utils/l10n"); +const { getStr } = require("../utils/l10n"); const { DOM: dom, createClass, PropTypes, addons } = require("devtools/client/shared/vendor/react"); diff --git a/devtools/client/responsive.html/components/global-toolbar.js b/devtools/client/responsive.html/components/global-toolbar.js index 863ab88e7bf9..5ac15d3ada91 100644 --- a/devtools/client/responsive.html/components/global-toolbar.js +++ b/devtools/client/responsive.html/components/global-toolbar.js @@ -4,23 +4,28 @@ "use strict"; -const { getStr } = require("./utils/l10n"); +const { getStr } = require("../utils/l10n"); const { DOM: dom, createClass, PropTypes, addons } = require("devtools/client/shared/vendor/react"); +const Types = require("../types"); module.exports = createClass({ displayName: "GlobalToolbar", - mixins: [ addons.PureRenderMixin ], - propTypes: { onExit: PropTypes.func.isRequired, + onScreenshot: PropTypes.func.isRequired, + screenshot: PropTypes.shape(Types.screenshot).isRequired, }, + mixins: [ addons.PureRenderMixin ], + render() { let { onExit, + onScreenshot, + screenshot, } = this.props; return dom.header( @@ -33,6 +38,13 @@ module.exports = createClass({ className: "title", }, getStr("responsive.title")), + dom.button({ + id: "global-screenshot-button", + className: "toolbar-button devtools-button", + title: getStr("responsive.screenshot"), + onClick: onScreenshot, + disabled: screenshot.isCapturing, + }), dom.button({ id: "global-exit-button", className: "toolbar-button devtools-button", diff --git a/devtools/client/responsive.html/components/moz.build b/devtools/client/responsive.html/components/moz.build index ef2b6dd5b15e..4ffc82150dad 100644 --- a/devtools/client/responsive.html/components/moz.build +++ b/devtools/client/responsive.html/components/moz.build @@ -4,10 +4,6 @@ # 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/. -DIRS += [ - 'utils', -] - DevToolsModules( 'browser.js', 'device-selector.js', diff --git a/devtools/client/responsive.html/components/resizable-viewport.js b/devtools/client/responsive.html/components/resizable-viewport.js index 068871fed85f..046dd6f67b62 100644 --- a/devtools/client/responsive.html/components/resizable-viewport.js +++ b/devtools/client/responsive.html/components/resizable-viewport.js @@ -24,6 +24,7 @@ module.exports = createClass({ propTypes: { devices: PropTypes.shape(Types.devices).isRequired, location: Types.location.isRequired, + screenshot: PropTypes.shape(Types.screenshot).isRequired, viewport: PropTypes.shape(Types.viewport).isRequired, onChangeViewportDevice: PropTypes.func.isRequired, onResizeViewport: PropTypes.func.isRequired, @@ -112,12 +113,19 @@ module.exports = createClass({ let { devices, location, + screenshot, viewport, onChangeViewportDevice, onResizeViewport, onRotateViewport, } = this.props; + let resizeHandleClass = "viewport-resize-handle"; + + if (screenshot.isCapturing) { + resizeHandleClass += " hidden"; + } + return dom.div( { className: "resizable-viewport", @@ -136,7 +144,7 @@ module.exports = createClass({ isResizing: this.state.isResizing }), dom.div({ - className: "viewport-resize-handle", + className: resizeHandleClass, onMouseDown: this.onResizeStart, }), dom.div({ diff --git a/devtools/client/responsive.html/components/viewport.js b/devtools/client/responsive.html/components/viewport.js index 86ba14b03b2c..7609aef05345 100644 --- a/devtools/client/responsive.html/components/viewport.js +++ b/devtools/client/responsive.html/components/viewport.js @@ -18,6 +18,7 @@ module.exports = createClass({ propTypes: { devices: PropTypes.shape(Types.devices).isRequired, location: Types.location.isRequired, + screenshot: PropTypes.shape(Types.screenshot).isRequired, viewport: PropTypes.shape(Types.viewport).isRequired, onChangeViewportDevice: PropTypes.func.isRequired, onResizeViewport: PropTypes.func.isRequired, @@ -55,6 +56,7 @@ module.exports = createClass({ let { devices, location, + screenshot, viewport, } = this.props; @@ -71,6 +73,7 @@ module.exports = createClass({ ResizableViewport({ devices, location, + screenshot, viewport, onChangeViewportDevice, onResizeViewport, diff --git a/devtools/client/responsive.html/components/viewports.js b/devtools/client/responsive.html/components/viewports.js index ff1790080281..62e6a34caf17 100644 --- a/devtools/client/responsive.html/components/viewports.js +++ b/devtools/client/responsive.html/components/viewports.js @@ -17,6 +17,7 @@ module.exports = createClass({ propTypes: { devices: PropTypes.shape(Types.devices).isRequired, location: Types.location.isRequired, + screenshot: PropTypes.shape(Types.screenshot).isRequired, viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired, onChangeViewportDevice: PropTypes.func.isRequired, onResizeViewport: PropTypes.func.isRequired, @@ -27,6 +28,7 @@ module.exports = createClass({ let { devices, location, + screenshot, viewports, onChangeViewportDevice, onResizeViewport, @@ -42,6 +44,7 @@ module.exports = createClass({ key: viewport.id, devices, location, + screenshot, viewport, onChangeViewportDevice, onResizeViewport, diff --git a/devtools/client/responsive.html/images/moz.build b/devtools/client/responsive.html/images/moz.build index a980f58cfd4d..6954beb23644 100644 --- a/devtools/client/responsive.html/images/moz.build +++ b/devtools/client/responsive.html/images/moz.build @@ -8,5 +8,6 @@ DevToolsModules( 'close.svg', 'grippers.svg', 'rotate-viewport.svg', + 'screenshot.svg', 'select-arrow.svg', ) diff --git a/devtools/client/responsive.html/images/screenshot.svg b/devtools/client/responsive.html/images/screenshot.svg new file mode 100644 index 000000000000..e176f6c7a3de --- /dev/null +++ b/devtools/client/responsive.html/images/screenshot.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/devtools/client/responsive.html/index.css b/devtools/client/responsive.html/index.css index f015e9cc7501..c65e4685a6d3 100644 --- a/devtools/client/responsive.html/index.css +++ b/devtools/client/responsive.html/index.css @@ -91,17 +91,28 @@ body { margin: 0 0 0 5px; } -#global-exit-button, -#global-exit-button::before { +#global-toolbar .toolbar-button, +#global-toolbar .toolbar-button::before { width: 12px; height: 12px; } +#global-screenshot-button::before { + background-image: url("./images/screenshot.svg"); + margin: -6px 0 0 -6px; +} + #global-exit-button::before { background-image: url("./images/close.svg"); margin: -6px 0 0 -6px; } +#global-screenshot-button:disabled { + filter: url("chrome://devtools/skin/images/filters.svg#checked-icon-state"); + opacity: 1 !important; +} + + #viewports { /* Snap to the top of the app when there isn't enough vertical space anymore to center the viewports (so we don't loose the toolbar) */ @@ -220,6 +231,10 @@ body { cursor: se-resize; } +.viewport-resize-handle.hidden { + display: none; +} + .viewport-horizontal-resize-handle { position: absolute; width: 5px; diff --git a/devtools/client/responsive.html/index.js b/devtools/client/responsive.html/index.js index b85c84d1d2db..942079754e93 100644 --- a/devtools/client/responsive.html/index.js +++ b/devtools/client/responsive.html/index.js @@ -42,10 +42,8 @@ let bootstrap = { "agent"); this.telemetry.toolOpened("responsive"); let store = this.store = Store(); - let app = App({ - onExit: () => window.postMessage({ type: "exit" }, "*"), - }); - let provider = createElement(Provider, { store }, app); + let provider = createElement(Provider, { store }, App()); + ReactDOM.render(provider, document.querySelector("#root")); this.initDevices(); window.postMessage({ type: "init" }, "*"); diff --git a/devtools/client/responsive.html/moz.build b/devtools/client/responsive.html/moz.build index d2a6ac50ce40..3c3c62e61f70 100644 --- a/devtools/client/responsive.html/moz.build +++ b/devtools/client/responsive.html/moz.build @@ -6,9 +6,11 @@ DIRS += [ 'actions', + 'audio', 'components', 'images', 'reducers', + 'utils', ] DevToolsModules( diff --git a/devtools/client/responsive.html/reducers.js b/devtools/client/responsive.html/reducers.js index 7c31d2542dfa..86c249a23c8e 100644 --- a/devtools/client/responsive.html/reducers.js +++ b/devtools/client/responsive.html/reducers.js @@ -6,4 +6,5 @@ exports.devices = require("./reducers/devices"); exports.location = require("./reducers/location"); +exports.screenshot = require("./reducers/screenshot"); exports.viewports = require("./reducers/viewports"); diff --git a/devtools/client/responsive.html/reducers/moz.build b/devtools/client/responsive.html/reducers/moz.build index e159a0da434a..584a216187f2 100644 --- a/devtools/client/responsive.html/reducers/moz.build +++ b/devtools/client/responsive.html/reducers/moz.build @@ -7,5 +7,6 @@ DevToolsModules( 'devices.js', 'location.js', + 'screenshot.js', 'viewports.js', ) diff --git a/devtools/client/responsive.html/reducers/screenshot.js b/devtools/client/responsive.html/reducers/screenshot.js new file mode 100644 index 000000000000..7df0643887f3 --- /dev/null +++ b/devtools/client/responsive.html/reducers/screenshot.js @@ -0,0 +1,31 @@ +/* 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 { + TAKE_SCREENSHOT_END, + TAKE_SCREENSHOT_START, +} = require("../actions/index"); + +const INITIAL_SCREENSHOT = { isCapturing: false }; + +let reducers = { + + [TAKE_SCREENSHOT_END](screenshot, action) { + return Object.assign({}, screenshot, { isCapturing: false }); + }, + + [TAKE_SCREENSHOT_START](screenshot, action) { + return Object.assign({}, screenshot, { isCapturing: true }); + }, +}; + +module.exports = function(screenshot = INITIAL_SCREENSHOT, action) { + let reducer = reducers[action.type]; + if (!reducer) { + return screenshot; + } + return reducer(screenshot, action); +}; diff --git a/devtools/client/responsive.html/test/browser/browser.ini b/devtools/client/responsive.html/test/browser/browser.ini index ed65c6608e92..2d826f319ad3 100644 --- a/devtools/client/responsive.html/test/browser/browser.ini +++ b/devtools/client/responsive.html/test/browser/browser.ini @@ -8,4 +8,5 @@ support-files = !/devtools/client/framework/test/shared-redux-head.js [browser_exit_button.js] +[browser_screenshot_button.js] [browser_viewport_basics.js] diff --git a/devtools/client/responsive.html/test/browser/browser_screenshot_button.js b/devtools/client/responsive.html/test/browser/browser_screenshot_button.js new file mode 100644 index 000000000000..60605c33b6d6 --- /dev/null +++ b/devtools/client/responsive.html/test/browser/browser_screenshot_button.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test global exit button + +const TEST_URL = "data:text/html;charset=utf-8,"; + +const { OS } = require("resource://gre/modules/osfile.jsm"); + +function* waitUntilScreenshot() { + return new Promise(Task.async(function* (resolve) { + let { Downloads } = require("resource://gre/modules/Downloads.jsm"); + let list = yield Downloads.getList(Downloads.ALL); + + let view = { + onDownloadAdded: download => { + download.whenSucceeded().then(() => { + resolve(download.target.path); + list.removeView(view); + }); + } + }; + + yield list.addView(view); + })); +} + +addRDMTask(TEST_URL, function* ({ ui: {toolWindow} }) { + let { store, document } = toolWindow; + + // Wait until the viewport has been added + yield waitUntilState(store, state => state.viewports.length == 1); + + info("Click the screenshot button"); + let screenshotButton = document.getElementById("global-screenshot-button"); + screenshotButton.click(); + + let whenScreenshotSucceeded = waitUntilScreenshot(); + + let filePath = yield whenScreenshotSucceeded; + let image = new Image(); + image.src = OS.Path.toFileURI(filePath); + + yield once(image, "load"); + + // We have only one viewport at the moment + let viewport = store.getState().viewports[0]; + let ratio = window.devicePixelRatio; + + is(image.width, viewport.width * ratio, + "screenshot width has the expected width"); + + is(image.height, viewport.height * ratio, + "screenshot width has the expected height"); + + yield OS.File.remove(filePath); +}); diff --git a/devtools/client/responsive.html/types.js b/devtools/client/responsive.html/types.js index 846f98100629..067cb8f42c91 100644 --- a/devtools/client/responsive.html/types.js +++ b/devtools/client/responsive.html/types.js @@ -70,6 +70,15 @@ exports.devices = { */ exports.location = PropTypes.string; +/** + * The progression of the screenshot + */ +exports.screenshot = { + + isCapturing: PropTypes.bool.isRequired, + +}; + /** * A single viewport displaying a document. */ diff --git a/devtools/client/responsive.html/components/utils/l10n.js b/devtools/client/responsive.html/utils/l10n.js similarity index 100% rename from devtools/client/responsive.html/components/utils/l10n.js rename to devtools/client/responsive.html/utils/l10n.js diff --git a/devtools/client/responsive.html/components/utils/moz.build b/devtools/client/responsive.html/utils/moz.build similarity index 100% rename from devtools/client/responsive.html/components/utils/moz.build rename to devtools/client/responsive.html/utils/moz.build