diff --git a/package.json b/package.json index 94358e3197..e843d0f133 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "cli-spinners": "^1.0.0", "clipboard-js": "^0.3.6", "css-loader": "^1.0.1", + "html2canvas": "^1.0.0-alpha.12", "error-stack-parser": "^2.0.2", "es6-symbol": "3.0.2", "escape-string-regexp": "^1.0.5", diff --git a/shells/browser/shared/src/injectGlobalHook.js b/shells/browser/shared/src/injectGlobalHook.js index 074b289db6..7a1147a040 100644 --- a/shells/browser/shared/src/injectGlobalHook.js +++ b/shells/browser/shared/src/injectGlobalHook.js @@ -2,7 +2,7 @@ import nullthrows from 'nullthrows'; import { installHook } from 'src/hook'; -import { RELOAD_AND_PROFILE_KEY } from 'src/constants'; +import { LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY } from 'src/constants'; function injectCode(code) { const script = document.createElement('script'); @@ -63,7 +63,7 @@ window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeSet = Set; `; // If we have just reloaded to profile, we need to inject the renderer interface before the app loads. -if (localStorage.getItem(RELOAD_AND_PROFILE_KEY) === 'true') { +if (localStorage.getItem(LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true') { const rendererURL = chrome.runtime.getURL('build/renderer.js'); let rendererCode; diff --git a/shells/browser/shared/src/main.js b/shells/browser/shared/src/main.js index a0e4a9a613..e96b10fd7c 100644 --- a/shells/browser/shared/src/main.js +++ b/shells/browser/shared/src/main.js @@ -12,7 +12,8 @@ import { } from './utils'; import DevTools from 'src/devtools/views/DevTools'; -const SUPPORTS_PROFILING_KEY = 'React::DevTools::supportsProfiling'; +const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY = + 'React::DevTools::supportsProfiling'; let panelCreated = false; @@ -64,7 +65,7 @@ function createPanelIfReactLoaded() { }, }); bridge.addListener('reloadAppForProfiling', () => { - localStorage.setItem(SUPPORTS_PROFILING_KEY, 'true'); + localStorage.setItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true'); chrome.devtools.inspectedWindow.eval('window.location.reload();'); }); bridge.addListener('exportFile', ({ contents, filename }) => { @@ -89,16 +90,19 @@ function createPanelIfReactLoaded() { // after a user has clicked the "reload and profile" button. let isProfiling = false; let supportsProfiling = false; - if (localStorage.getItem(SUPPORTS_PROFILING_KEY) === 'true') { + if ( + localStorage.getItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY) === 'true' + ) { supportsProfiling = true; isProfiling = true; - localStorage.removeItem(SUPPORTS_PROFILING_KEY); + localStorage.removeItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY); } const browserName = getBrowserName(); store = new Store(bridge, { isProfiling, + supportsCaptureScreenshots: true, supportsFileDownloads: browserName === 'Chrome', supportsReloadAndProfile: true, supportsProfiling, diff --git a/shells/dev/src/backend.js b/shells/dev/src/backend.js index c387b52c33..02ff78fd19 100644 --- a/shells/dev/src/backend.js +++ b/shells/dev/src/backend.js @@ -1,5 +1,6 @@ /** @flow */ +import html2canvas from 'html2canvas'; import Agent from 'src/backend/agent'; import Bridge from 'src/bridge'; import { initBackend } from 'src/backend'; @@ -15,6 +16,15 @@ const bridge = new Bridge({ }, }); +bridge.addListener('captureScreenshot', ({ commitIndex }) => { + html2canvas(document.body, { logging: false }).then(canvas => { + bridge.send('screenshotCaptured', { + commitIndex, + dataURL: canvas.toDataURL(), + }); + }); +}); + const agent = new Agent(); agent.addBridge(bridge); diff --git a/shells/dev/src/devtools.js b/shells/dev/src/devtools.js index b248b67019..3fb49174c5 100644 --- a/shells/dev/src/devtools.js +++ b/shells/dev/src/devtools.js @@ -54,7 +54,7 @@ inject('./build/app.js', () => { cb(bridge); - const store = new Store(bridge); + const store = new Store(bridge, { supportsCaptureScreenshots: true }); const root = createRoot(container); const batch = root.createBatch(); diff --git a/src/backend/agent.js b/src/backend/agent.js index b114c22795..390772f358 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -1,7 +1,7 @@ // @flow import EventEmitter from 'events'; -import { RELOAD_AND_PROFILE_KEY, __DEBUG__ } from '../constants'; +import { LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY, __DEBUG__ } from '../constants'; import { hideOverlay, showOverlay } from './views/Highlighter'; import type { RendererID, RendererInterface } from './types'; @@ -46,10 +46,10 @@ export default class Agent extends EventEmitter { constructor() { super(); - if (localStorage.getItem(RELOAD_AND_PROFILE_KEY) === 'true') { + if (localStorage.getItem(LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true') { this._isProfiling = true; - localStorage.removeItem(RELOAD_AND_PROFILE_KEY); + localStorage.removeItem(LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY); } } @@ -233,7 +233,7 @@ export default class Agent extends EventEmitter { }; reloadAndProfile = () => { - localStorage.setItem(RELOAD_AND_PROFILE_KEY, 'true'); + localStorage.setItem(LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY, 'true'); // This code path should only be hit if the shell has explicitly told the Store that it supports profiling. // In that case, the shell must also listen for this specific message to know when it needs to reload the app. diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 21322e6e5b..1046f6b838 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -18,7 +18,7 @@ import { getDisplayName, utfEncodeString } from '../utils'; import { cleanForBridge, copyWithSet, setInObject } from './utils'; import { __DEBUG__, - RELOAD_AND_PROFILE_KEY, + LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY, TREE_OPERATION_ADD, TREE_OPERATION_REMOVE, TREE_OPERATION_RESET_CHILDREN, @@ -1642,7 +1642,7 @@ export function attach( } // Automatically start profiling so that we don't miss timing info from initial "mount". - if (localStorage.getItem(RELOAD_AND_PROFILE_KEY) === 'true') { + if (localStorage.getItem(LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true') { startProfiling(); } diff --git a/src/constants.js b/src/constants.js index 3523aa5214..ef69e21760 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,6 +5,7 @@ export const TREE_OPERATION_REMOVE = 2; export const TREE_OPERATION_RESET_CHILDREN = 3; export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4; -export const RELOAD_AND_PROFILE_KEY = 'React::DevTools::reloadAndProfile'; +export const LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY = + 'React::DevTools::reloadAndProfile'; export const __DEBUG__ = false; diff --git a/src/devtools/store.js b/src/devtools/store.js index 838563fb0f..2294ca89bd 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -31,8 +31,12 @@ const debug = (methodName, ...args) => { } }; +const LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY = + 'React::DevTools::captureScreenshots'; + type Config = {| isProfiling?: boolean, + supportsCaptureScreenshots?: boolean, supportsFileDownloads?: boolean, supportsReloadAndProfile?: boolean, supportsProfiling?: boolean, @@ -49,6 +53,8 @@ export type Capabilities = {| export default class Store extends EventEmitter { _bridge: Bridge; + _captureScreenshots: boolean = false; + // Map of ID to Element. // Elements are mutable (for now) to avoid excessive cloning during tree updates. _idToElement: Map = new Map(); @@ -96,6 +102,7 @@ export default class Store extends EventEmitter { // These options may be initially set by a confiugraiton option when constructing the Store. // In the case of "supportsProfiling", the option may be updated based on the injected renderers. + _supportsCaptureScreenshots: boolean = false; _supportsFileDownloads: boolean = false; _supportsProfiling: boolean = false; _supportsReloadAndProfile: boolean = false; @@ -108,6 +115,7 @@ export default class Store extends EventEmitter { if (config != null) { const { isProfiling, + supportsCaptureScreenshots, supportsFileDownloads, supportsProfiling, supportsReloadAndProfile, @@ -115,6 +123,12 @@ export default class Store extends EventEmitter { if (isProfiling) { this._isProfiling = true; } + if (supportsCaptureScreenshots) { + this._supportsCaptureScreenshots = true; + this._captureScreenshots = + localStorage.getItem(LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY) !== + 'false'; + } if (supportsFileDownloads) { this._supportsFileDownloads = true; } @@ -139,6 +153,20 @@ export default class Store extends EventEmitter { this._profilingCache = new ProfilingCache(bridge, this); } + get captureScreenshots(): boolean { + return this._captureScreenshots; + } + set captureScreenshots(value: boolean): void { + this._captureScreenshots = value; + + localStorage.setItem( + LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY, + value ? 'true' : 'false' + ); + + this.emit('captureScreenshots'); + } + // Profiling data has been recorded for at least one root. get hasProfilingData(): boolean { return ( @@ -190,6 +218,10 @@ export default class Store extends EventEmitter { return this._roots; } + get supportsCaptureScreenshots(): boolean { + return this._supportsCaptureScreenshots; + } + get supportsFileDownloads(): boolean { return this._supportsFileDownloads; } @@ -419,7 +451,9 @@ export default class Store extends EventEmitter { const commitIndex = profilingOperations.length - 1; - this._bridge.send('captureScreenshot', { commitIndex }); + if (this._captureScreenshots) { + this._bridge.send('captureScreenshot', { commitIndex }); + } } let addedElementIDs: Uint32Array = new Uint32Array(0); diff --git a/src/devtools/views/Profiler/SidebarCommitInfo.css b/src/devtools/views/Profiler/SidebarCommitInfo.css index a7eba6a3ba..a9bd56a2e7 100644 --- a/src/devtools/views/Profiler/SidebarCommitInfo.css +++ b/src/devtools/views/Profiler/SidebarCommitInfo.css @@ -55,6 +55,9 @@ .Screenshot { width: 100%; + border: 1px solid var(--color-border); + border-radius: 0.25rem; + cursor: pointer; } .Modal { @@ -73,4 +76,6 @@ .ModalImage { max-height: 100%; max-width: 100%; + border: 1px solid var(--color-border); + border-radius: 0.5rem; } diff --git a/src/devtools/views/Settings/Settings.js b/src/devtools/views/Settings/Settings.js index 97d366f2e3..808697ea4c 100644 --- a/src/devtools/views/Settings/Settings.js +++ b/src/devtools/views/Settings/Settings.js @@ -1,8 +1,11 @@ // @flow -import React, { useCallback, useContext } from 'react'; +import React, { useCallback, useContext, useMemo } from 'react'; import { createPortal } from 'react-dom'; +import { useSubscription } from '../hooks'; +import { StoreContext } from '../context'; import { SettingsContext } from './SettingsContext'; +import Store from 'src/devtools/store'; import styles from './Settings.css'; @@ -11,10 +14,23 @@ export type Props = {| |}; export default function Settings({ portalContainer }: Props) { + const store = useContext(StoreContext); const { displayDensity, setDisplayDensity, theme, setTheme } = useContext( SettingsContext ); + const subscription = useMemo( + () => ({ + getCurrentValue: () => store.captureScreenshots, + subscribe: (callback: Function) => { + store.addListener('captureScreenshots', callback); + return () => store.removeListener('captureScreenshots', callback); + }, + }), + [store] + ); + const captureScreenshots = useSubscription(subscription); + const updateDisplayDensity = useCallback( ({ currentTarget }) => { setDisplayDensity(currentTarget.value); @@ -29,6 +45,13 @@ export default function Settings({ portalContainer }: Props) { [setTheme] ); + const updateCaptureScreenshotsWhileProfiling = useCallback( + ({ currentTarget }) => { + store.captureScreenshots = currentTarget.checked; + }, + [store] + ); + const children = (
@@ -91,6 +114,21 @@ export default function Settings({ portalContainer }: Props) {
+ {store.supportsCaptureScreenshots && ( +
+
Profiler
+
+ +
+
+ )} ); diff --git a/yarn.lock b/yarn.lock index 3779ffc9ca..622ecd77fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2038,6 +2038,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-arraybuffer@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8" + integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg= + base64-js@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" @@ -3259,6 +3264,13 @@ crypto-random-string@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" +css-line-break@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/css-line-break/-/css-line-break-1.0.1.tgz#19f2063a33e95fb2831b86446c0b80c188af450a" + integrity sha1-GfIGOjPpX7KDG4ZEbAuAwYivRQo= + dependencies: + base64-arraybuffer "^0.1.5" + css-loader@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-1.0.1.tgz#6885bb5233b35ec47b006057da01cc640b6b79fe" @@ -5392,6 +5404,13 @@ html-encoding-sniffer@^1.0.2: dependencies: whatwg-encoding "^1.0.1" +html2canvas@^1.0.0-alpha.12: + version "1.0.0-alpha.12" + resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.0.0-alpha.12.tgz#3b1992e3c9b3f56063c35fd620494f37eba88513" + integrity sha1-OxmS48mz9WBjw1/WIElPN+uohRM= + dependencies: + css-line-break "1.0.1" + htmlparser2@^3.9.1: version "3.9.2" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338"