diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index 0cb5f8b9364d5..fb920df93807e 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -4,11 +4,7 @@ import {createElement} from 'react'; import {createRoot, flushSync} from 'react-dom'; import Bridge from 'react-devtools-shared/src/bridge'; import Store from 'react-devtools-shared/src/devtools/store'; -import { - createViewElementSource, - getBrowserName, - getBrowserTheme, -} from './utils'; +import {getBrowserName, getBrowserTheme} from './utils'; import {LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY} from 'react-devtools-shared/src/constants'; import { getSavedComponentFilters, @@ -155,10 +151,54 @@ function createPanelIfReactLoaded() { }, ); - const viewElementSourceFunction = createViewElementSource( - bridge, - store, - ); + const viewAttributeSourceFunction = (id, path) => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID != null) { + // Ask the renderer interface to find the specified attribute, + // and store it as a global variable on the window. + bridge.send('viewAttributeSource', {id, path, rendererID}); + + setTimeout(() => { + // Ask Chrome to display the location of the attribute, + // assuming the renderer found a match. + chrome.devtools.inspectedWindow.eval(` + if (window.$attribute != null) { + inspect(window.$attribute); + } + `); + }, 100); + } + }; + + const viewElementSourceFunction = id => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID != null) { + // Ask the renderer interface to determine the component function, + // and store it as a global variable on the window + bridge.send('viewElementSource', {id, rendererID}); + + setTimeout(() => { + // Ask Chrome to display the location of the component function, + // or a render method if it is a Class (ideally Class instance, not type) + // assuming the renderer found one. + chrome.devtools.inspectedWindow.eval(` + if (window.$type != null) { + if ( + window.$type && + window.$type.prototype && + window.$type.prototype.isReactComponent + ) { + // inspect Component.render, not constructor + inspect(window.$type.prototype.render); + } else { + // inspect Functional Component + inspect(window.$type); + } + } + `); + }, 100); + } + }; root = createRoot(document.createElement('div')); @@ -170,11 +210,13 @@ function createPanelIfReactLoaded() { bridge, browserTheme: getBrowserTheme(), componentsPortalContainer, + enabledInspectedElementContextMenu: true, overrideTab, profilerPortalContainer, showTabBar: false, - warnIfUnsupportedVersionDetected: true, store, + warnIfUnsupportedVersionDetected: true, + viewAttributeSourceFunction, viewElementSourceFunction, }), ); diff --git a/packages/react-devtools-extensions/src/utils.js b/packages/react-devtools-extensions/src/utils.js index 922b1717563b5..4fc27de2ee6a1 100644 --- a/packages/react-devtools-extensions/src/utils.js +++ b/packages/react-devtools-extensions/src/utils.js @@ -2,38 +2,6 @@ const IS_CHROME = navigator.userAgent.indexOf('Firefox') < 0; -export function createViewElementSource(bridge: Bridge, store: Store) { - return function viewElementSource(id) { - const rendererID = store.getRendererIDForElement(id); - if (rendererID != null) { - // Ask the renderer interface to determine the component function, - // and store it as a global variable on the window - bridge.send('viewElementSource', {id, rendererID}); - - setTimeout(() => { - // Ask Chrome to display the location of the component function, - // or a render method if it is a Class (ideally Class instance, not type) - // assuming the renderer found one. - chrome.devtools.inspectedWindow.eval(` - if (window.$type != null) { - if ( - window.$type && - window.$type.prototype && - window.$type.prototype.isReactComponent - ) { - // inspect Component.render, not constructor - inspect(window.$type.prototype.render); - } else { - // inspect Functional Component - inspect(window.$type); - } - } - `); - }, 100); - } - }; -} - export type BrowserName = 'Chrome' | 'Firefox'; export function getBrowserName(): BrowserName { diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js index f476cfee365da..c48711cce3bbd 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js @@ -8,7 +8,11 @@ */ import typeof ReactTestRenderer from 'react-test-renderer'; -import type {GetInspectedElementPath} from 'react-devtools-shared/src/devtools/views/Components/InspectedElementContext'; +import type { + CopyInspectedElementPath, + GetInspectedElementPath, + StoreAsGlobal, +} from 'react-devtools-shared/src/devtools/views/Components/InspectedElementContext'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; import type Store from 'react-devtools-shared/src/devtools/store'; @@ -1203,4 +1207,139 @@ describe('InspectedElementContext', () => { done(); }); + + it('should enable inspected values to be stored as global variables', async done => { + const Example = () => null; + + const nestedObject = { + a: { + value: 1, + b: { + value: 1, + c: { + value: 1, + }, + }, + }, + }; + + await utils.actAsync(() => + ReactDOM.render( + , + document.createElement('div'), + ), + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let storeAsGlobal: StoreAsGlobal = ((null: any): StoreAsGlobal); + + function Suspender({target}) { + const context = React.useContext(InspectedElementContext); + storeAsGlobal = context.storeAsGlobal; + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + , + ), + false, + ); + expect(storeAsGlobal).not.toBeNull(); + + const logSpy = jest.fn(); + spyOn(console, 'log').and.callFake(logSpy); + + // Should store the whole value (not just the hydrated parts) + storeAsGlobal(id, ['props', 'nestedObject']); + jest.runOnlyPendingTimers(); + expect(logSpy).toHaveBeenCalledWith('$reactTemp1'); + expect(global.$reactTemp1).toBe(nestedObject); + + logSpy.mockReset(); + + // Should store the nested property specified (not just the outer value) + storeAsGlobal(id, ['props', 'nestedObject', 'a', 'b']); + jest.runOnlyPendingTimers(); + expect(logSpy).toHaveBeenCalledWith('$reactTemp2'); + expect(global.$reactTemp2).toBe(nestedObject.a.b); + + done(); + }); + + it('should enable inspected values to be copied to the clipboard', async done => { + const Example = () => null; + + const nestedObject = { + a: { + value: 1, + b: { + value: 1, + c: { + value: 1, + }, + }, + }, + }; + + await utils.actAsync(() => + ReactDOM.render( + , + document.createElement('div'), + ), + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let copyPath: CopyInspectedElementPath = ((null: any): CopyInspectedElementPath); + + function Suspender({target}) { + const context = React.useContext(InspectedElementContext); + copyPath = context.copyInspectedElementPath; + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + , + ), + false, + ); + expect(copyPath).not.toBeNull(); + + // Should copy the whole value (not just the hydrated parts) + copyPath(id, ['props', 'nestedObject']); + jest.runOnlyPendingTimers(); + expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1); + expect(global.mockClipboardCopy).toHaveBeenCalledWith( + JSON.stringify(nestedObject), + ); + + global.mockClipboardCopy.mockReset(); + + // Should copy the nested property specified (not just the outer value) + copyPath(id, ['props', 'nestedObject', 'a', 'b']); + jest.runOnlyPendingTimers(); + expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1); + expect(global.mockClipboardCopy).toHaveBeenCalledWith( + JSON.stringify(nestedObject.a.b), + ); + + done(); + }); }); diff --git a/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js b/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js index 8c595d734f2fd..97aa706152e5e 100644 --- a/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js @@ -392,4 +392,109 @@ describe('InspectedElementContext', () => { done(); }); + + it('should enable inspected values to be stored as global variables', () => { + const Example = () => null; + + const nestedObject = { + a: { + value: 1, + b: { + value: 1, + c: { + value: 1, + }, + }, + }, + }; + + act(() => + ReactDOM.render( + , + document.createElement('div'), + ), + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const rendererID = ((store.getRendererIDForElement(id): any): number); + + const logSpy = jest.fn(); + spyOn(console, 'log').and.callFake(logSpy); + + // Should store the whole value (not just the hydrated parts) + bridge.send('storeAsGlobal', { + count: 1, + id, + path: ['props', 'nestedObject'], + rendererID, + }); + jest.runOnlyPendingTimers(); + expect(logSpy).toHaveBeenCalledWith('$reactTemp1'); + expect(global.$reactTemp1).toBe(nestedObject); + + logSpy.mockReset(); + + // Should store the nested property specified (not just the outer value) + bridge.send('storeAsGlobal', { + count: 2, + id, + path: ['props', 'nestedObject', 'a', 'b'], + rendererID, + }); + jest.runOnlyPendingTimers(); + expect(logSpy).toHaveBeenCalledWith('$reactTemp2'); + expect(global.$reactTemp2).toBe(nestedObject.a.b); + }); + + it('should enable inspected values to be copied to the clipboard', () => { + const Example = () => null; + + const nestedObject = { + a: { + value: 1, + b: { + value: 1, + c: { + value: 1, + }, + }, + }, + }; + + act(() => + ReactDOM.render( + , + document.createElement('div'), + ), + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const rendererID = ((store.getRendererIDForElement(id): any): number); + + // Should copy the whole value (not just the hydrated parts) + bridge.send('copyElementPath', { + id, + path: ['props', 'nestedObject'], + rendererID, + }); + jest.runOnlyPendingTimers(); + expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1); + expect(global.mockClipboardCopy).toHaveBeenCalledWith( + JSON.stringify(nestedObject), + ); + + global.mockClipboardCopy.mockReset(); + + // Should copy the nested property specified (not just the outer value) + bridge.send('copyElementPath', { + id, + path: ['props', 'nestedObject', 'a', 'b'], + rendererID, + }); + jest.runOnlyPendingTimers(); + expect(global.mockClipboardCopy).toHaveBeenCalledTimes(1); + expect(global.mockClipboardCopy).toHaveBeenCalledWith( + JSON.stringify(nestedObject.a.b), + ); + }); }); diff --git a/packages/react-devtools-shared/src/__tests__/setupTests.js b/packages/react-devtools-shared/src/__tests__/setupTests.js index 5d5511207b3e8..278419c814f85 100644 --- a/packages/react-devtools-shared/src/__tests__/setupTests.js +++ b/packages/react-devtools-shared/src/__tests__/setupTests.js @@ -14,6 +14,13 @@ import type { const env = jasmine.getEnv(); env.beforeEach(() => { + global.mockClipboardCopy = jest.fn(); + + // Test environment doesn't support document methods like execCommand() + // Also once the backend components below have been required, + // it's too late for a test to mock the clipboard-js modules. + jest.mock('clipboard-js', () => ({copy: global.mockClipboardCopy})); + // These files should be required (and re-reuired) before each test, // rather than imported at the head of the module. // That's because we reset modules between tests, diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 5161b434c5bad..73c09f6a84239 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -55,6 +55,19 @@ type ElementAndRendererID = {| rendererID: number, |}; +type StoreAsGlobalParams = {| + count: number, + id: number, + path: Array, + rendererID: number, +|}; + +type CopyElementParams = {| + id: number, + path: Array, + rendererID: number, +|}; + type InspectElementParams = {| id: number, path?: Array, @@ -126,6 +139,7 @@ export default class Agent extends EventEmitter<{| this._bridge = bridge; + bridge.addListener('copyElementPath', this.copyElementPath); bridge.addListener('getProfilingData', this.getProfilingData); bridge.addListener('getProfilingStatus', this.getProfilingStatus); bridge.addListener('getOwnersList', this.getOwnersList); @@ -140,6 +154,7 @@ export default class Agent extends EventEmitter<{| bridge.addListener('setTraceUpdatesEnabled', this.setTraceUpdatesEnabled); bridge.addListener('startProfiling', this.startProfiling); bridge.addListener('stopProfiling', this.stopProfiling); + bridge.addListener('storeAsGlobal', this.storeAsGlobal); bridge.addListener( 'syncSelectionFromNativeElementsPanel', this.syncSelectionFromNativeElementsPanel, @@ -150,6 +165,7 @@ export default class Agent extends EventEmitter<{| this.updateAppendComponentStack, ); bridge.addListener('updateComponentFilters', this.updateComponentFilters); + bridge.addListener('viewAttributeSource', this.viewAttributeSource); bridge.addListener('viewElementSource', this.viewElementSource); if (this._isProfiling) { @@ -173,6 +189,15 @@ export default class Agent extends EventEmitter<{| return this._rendererInterfaces; } + copyElementPath = ({id, path, rendererID}: CopyElementParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.copyElementPath(id, path); + } + }; + getInstanceAndStyle({ id, rendererID, @@ -409,6 +434,15 @@ export default class Agent extends EventEmitter<{| this._bridge.send('profilingStatus', this._isProfiling); }; + storeAsGlobal = ({count, id, path, rendererID}: StoreAsGlobalParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.storeAsGlobal(id, path, count); + } + }; + updateAppendComponentStack = (appendComponentStack: boolean) => { // If the frontend preference has change, // or in the case of React Native- if the backend is just finding out the preference- @@ -430,6 +464,15 @@ export default class Agent extends EventEmitter<{| } }; + viewAttributeSource = ({id, path, rendererID}: CopyElementParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.prepareViewAttributeSource(id, path); + } + }; + viewElementSource = ({id, rendererID}: ElementAndRendererID) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 3e7232874504b..310941f8e21d2 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -15,8 +15,8 @@ import { ElementTypeOtherOrUnknown, } from 'react-devtools-shared/src/types'; import {getUID, utfEncodeString, printOperationsArray} from '../../utils'; -import {cleanForBridge, copyWithSet} from '../utils'; -import {getDisplayName} from 'react-devtools-shared/src/utils'; +import {cleanForBridge, copyToClipboard, copyWithSet} from '../utils'; +import {getDisplayName, getInObject} from 'react-devtools-shared/src/utils'; import { __DEBUG__, TREE_OPERATION_ADD, @@ -649,6 +649,30 @@ export function attach( } } + function storeAsGlobal( + id: number, + path: Array, + count: number, + ): void { + const inspectedElement = inspectElementRaw(id); + if (inspectedElement !== null) { + const value = getInObject(inspectedElement, path); + const key = `$reactTemp${count}`; + + window[key] = value; + + console.log(key); + console.log(value); + } + } + + function copyElementPath(id: number, path: Array): void { + const inspectedElement = inspectElementRaw(id); + if (inspectedElement !== null) { + copyToClipboard(getInObject(inspectedElement, path)); + } + } + function inspectElement( id: number, path?: Array, @@ -812,6 +836,16 @@ export function attach( } } + function prepareViewAttributeSource( + id: number, + path: Array, + ): void { + const inspectedElement = inspectElementRaw(id); + if (inspectedElement !== null) { + window.$attribute = getInObject(inspectedElement, path); + } + } + function prepareViewElementSource(id: number): void { const internalInstance = idToInternalInstanceMap.get(id); if (internalInstance == null) { @@ -927,6 +961,7 @@ export function attach( return { cleanup, + copyElementPath, flushInitialOperations, getBestMatchForTrackedPath, getFiberIDForNative: getInternalIDForNative, @@ -943,6 +978,7 @@ export function attach( inspectElement, logElementToConsole, overrideSuspense, + prepareViewAttributeSource, prepareViewElementSource, renderer, setInContext, @@ -953,6 +989,7 @@ export function attach( setTrackedPath, startProfiling, stopProfiling, + storeAsGlobal, updateComponentFilters, }; } diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index f62255579054e..0b1c35be6f595 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -34,7 +34,7 @@ import { utfEncodeString, } from 'react-devtools-shared/src/utils'; import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; -import {cleanForBridge, copyWithSet} from './utils'; +import {cleanForBridge, copyToClipboard, copyWithSet} from './utils'; import { __DEBUG__, SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, @@ -2113,6 +2113,19 @@ export function attach( } // END copied code + function prepareViewAttributeSource( + id: number, + path: Array, + ): void { + const isCurrent = isMostRecentlyInspectedElementCurrent(id); + if (isCurrent) { + window.$attribute = getInObject( + ((mostRecentlyInspectedElement: any): InspectedElement), + path, + ); + } + } + function prepareViewElementSource(id: number): void { let fiber = idToFiberMap.get(id); if (fiber == null) { @@ -2488,6 +2501,40 @@ export function attach( } } + function storeAsGlobal( + id: number, + path: Array, + count: number, + ): void { + const isCurrent = isMostRecentlyInspectedElementCurrent(id); + + if (isCurrent) { + const value = getInObject( + ((mostRecentlyInspectedElement: any): InspectedElement), + path, + ); + const key = `$reactTemp${count}`; + + window[key] = value; + + console.log(key); + console.log(value); + } + } + + function copyElementPath(id: number, path: Array): void { + const isCurrent = isMostRecentlyInspectedElementCurrent(id); + + if (isCurrent) { + copyToClipboard( + getInObject( + ((mostRecentlyInspectedElement: any): InspectedElement), + path, + ), + ); + } + } + function inspectElement( id: number, path?: Array, @@ -3129,6 +3176,7 @@ export function attach( return { cleanup, + copyElementPath, findNativeNodesForFiberID, flushInitialOperations, getBestMatchForTrackedPath, @@ -3141,6 +3189,7 @@ export function attach( handleCommitFiberUnmount, inspectElement, logElementToConsole, + prepareViewAttributeSource, prepareViewElementSource, overrideSuspense, renderer, @@ -3152,6 +3201,7 @@ export function attach( setTrackedPath, startProfiling, stopProfiling, + storeAsGlobal, updateComponentFilters, }; } diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 044c153cceeab..8f738fbfe94cf 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -225,6 +225,7 @@ export type InstanceAndStyle = {| export type RendererInterface = { cleanup: () => void, + copyElementPath: (id: number, path: Array) => void, findNativeNodesForFiberID: FindNativeNodesForFiberID, flushInitialOperations: () => void, getBestMatchForTrackedPath: () => PathMatch | null, @@ -241,6 +242,10 @@ export type RendererInterface = { ) => InspectedElementPayload, logElementToConsole: (id: number) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, + prepareViewAttributeSource: ( + id: number, + path: Array, + ) => void, prepareViewElementSource: (id: number) => void, renderer: ReactRenderer | null, setInContext: (id: number, path: Array, value: any) => void, @@ -256,6 +261,11 @@ export type RendererInterface = { setTrackedPath: (path: Array | null) => void, startProfiling: (recordChangeDescriptions: boolean) => void, stopProfiling: () => void, + storeAsGlobal: ( + id: number, + path: Array, + count: number, + ) => void, updateComponentFilters: (componentFilters: Array) => void, }; diff --git a/packages/react-devtools-shared/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index f6a2494ba7c79..47355f2072a7c 100644 --- a/packages/react-devtools-shared/src/backend/utils.js +++ b/packages/react-devtools-shared/src/backend/utils.js @@ -7,6 +7,7 @@ * @flow */ +import {copy} from 'clipboard-js'; import {dehydrate} from '../hydration'; import type {DehydratedData} from 'react-devtools-shared/src/devtools/views/Components/types'; @@ -37,6 +38,11 @@ export function cleanForBridge( } } +export function copyToClipboard(value: any): void { + const safeToCopy = serializeToString(value); + copy(safeToCopy === undefined ? 'undefined' : safeToCopy); +} + export function copyWithSet( obj: Object | Array, path: Array, @@ -52,3 +58,17 @@ export function copyWithSet( updated[key] = copyWithSet(obj[key], path, value, index + 1); return updated; } + +export function serializeToString(data: any): string { + const cache = new Set(); + // Use a custom replacer function to protect against circular references. + return JSON.stringify(data, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (cache.has(value)) { + return; + } + cache.add(value); + } + return value; + }); +} diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index f6b39afac7e23..7bdc37971ee37 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -51,11 +51,27 @@ type OverrideSuspense = {| forceFallback: boolean, |}; +type CopyElementPathParams = {| + ...ElementAndRendererID, + path: Array, +|}; + +type ViewAttributeSourceParams = {| + ...ElementAndRendererID, + path: Array, +|}; + type InspectElementParams = {| ...ElementAndRendererID, path?: Array, |}; +type StoreAsGlobalParams = {| + ...ElementAndRendererID, + count: number, + path: Array, +|}; + type NativeStyleEditor_RenameAttributeParams = {| ...ElementAndRendererID, oldName: string, @@ -95,6 +111,7 @@ type BackendEvents = {| type FrontendEvents = {| clearNativeElementHighlight: [], + copyElementPath: [CopyElementPathParams], getOwnersList: [ElementAndRendererID], getProfilingData: [{|rendererID: RendererID|}], getProfilingStatus: [], @@ -115,8 +132,10 @@ type FrontendEvents = {| startProfiling: [boolean], stopInspectingNative: [boolean], stopProfiling: [], + storeAsGlobal: [StoreAsGlobalParams], updateAppendComponentStack: [boolean], updateComponentFilters: [Array], + viewAttributeSource: [ViewAttributeSourceParams], viewElementSource: [ElementAndRendererID], // React Native style editor plug-in. diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css new file mode 100644 index 0000000000000..20af7c096f059 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css @@ -0,0 +1,7 @@ +.ContextMenu { + position: absolute; + background-color: var(--color-context-background); + border-radius: 0.25rem; + overflow: hidden; + z-index: 10000002; +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js new file mode 100644 index 0000000000000..b0e703af9e37e --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js @@ -0,0 +1,128 @@ +import React, { + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import {createPortal} from 'react-dom'; +import {RegistryContext} from './Contexts'; + +import styles from './ContextMenu.css'; + +function respositionToFit(element, pageX, pageY) { + const ownerWindow = element.ownerDocument.defaultView; + if (element !== null) { + if (pageY + element.offsetHeight >= ownerWindow.innerHeight) { + if (pageY - element.offsetHeight > 0) { + element.style.top = `${pageY - element.offsetHeight}px`; + } else { + element.style.top = '0px'; + } + } else { + element.style.top = `${pageY}px`; + } + + if (pageX + element.offsetWidth >= ownerWindow.innerWidth) { + if (pageX - element.offsetWidth > 0) { + element.style.left = `${pageX - element.offsetWidth}px`; + } else { + element.style.left = '0px'; + } + } else { + element.style.left = `${pageX}px`; + } + } +} + +const HIDDEN_STATE = { + data: null, + isVisible: false, + pageX: 0, + pageY: 0, +}; + +type Props = {| + children: React$Node, + id: string, +|}; + +export default function ContextMenu({children, id}: Props) { + const {registerMenu} = useContext(RegistryContext); + + const [state, setState] = useState(HIDDEN_STATE); + + const bodyAccessorRef = useRef(null); + const containerRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + const ownerDocument = bodyAccessorRef.current.ownerDocument; + containerRef.current = ownerDocument.createElement('div'); + ownerDocument.body.appendChild(containerRef.current); + return () => { + ownerDocument.body.removeChild(containerRef.current); + }; + }, []); + + useEffect( + () => { + const showMenu = ({data, pageX, pageY}) => { + setState({data, isVisible: true, pageX, pageY}); + }; + const hideMenu = () => setState(HIDDEN_STATE); + return registerMenu(id, showMenu, hideMenu); + }, + [id], + ); + + useLayoutEffect( + () => { + if (!state.isVisible) { + return; + } + + const menu = menuRef.current; + + const hideUnlessContains = event => { + if (!menu.contains(event.target)) { + setState(HIDDEN_STATE); + } + }; + + const hide = event => { + setState(HIDDEN_STATE); + }; + + const ownerDocument = containerRef.current.ownerDocument; + ownerDocument.addEventListener('mousedown', hideUnlessContains); + ownerDocument.addEventListener('touchstart', hideUnlessContains); + ownerDocument.addEventListener('keydown', hideUnlessContains); + + const ownerWindow = ownerDocument.defaultView; + ownerWindow.addEventListener('resize', hide); + + respositionToFit(menu, state.pageX, state.pageY); + + return () => { + ownerDocument.removeEventListener('mousedown', hideUnlessContains); + ownerDocument.removeEventListener('touchstart', hideUnlessContains); + ownerDocument.removeEventListener('keydown', hideUnlessContains); + + ownerWindow.removeEventListener('resize', hide); + }; + }, + [state], + ); + + if (!state.isVisible) { + return
; + } else { + return createPortal( +
+ {children(state.data)} +
, + containerRef.current, + ); + } +} diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css new file mode 100644 index 0000000000000..1b36ea76c0142 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css @@ -0,0 +1,22 @@ +.ContextMenuItem { + display: flex; + align-items: center; + color: var(--color-context-text); + padding: 0.5rem 0.75rem; + cursor: default; + border-top: 1px solid var(--color-context-border); + font-family: var(--font-family-sans); + font-size: var(--font-size-sans-normal); +} +.ContextMenuItem:first-of-type { + border-top: none; +} +.ContextMenuItem:hover, +.ContextMenuItem:focus { + outline: 0; + background-color: var(--color-context-background-hover); +} +.ContextMenuItem:active { + background-color: var(--color-context-background-selected); + color: var(--color-context-text-selected); +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js new file mode 100644 index 0000000000000..162cd51d95d83 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js @@ -0,0 +1,28 @@ +import React, {useContext} from 'react'; +import {RegistryContext} from './Contexts'; + +import styles from './ContextMenuItem.css'; + +type Props = {| + children: React$Node, + onClick: Object => void, + title: string, +|}; + +export default function ContextMenuItem({children, onClick, title}: Props) { + const {hideMenu} = useContext(RegistryContext); + + const handleClick = event => { + onClick(); + hideMenu(); + }; + + return ( +
+ {children} +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js b/packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js new file mode 100644 index 0000000000000..b237a2a71537a --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js @@ -0,0 +1,53 @@ +import {createContext} from 'react'; + +export type ShowFn = ({data: Object, pageX: number, pageY: number}) => void; +export type HideFn = () => void; + +const idToShowFnMap = new Map(); +const idToHideFnMap = new Map(); + +let currentHideFn = null; + +function hideMenu() { + if (typeof currentHideFn === 'function') { + currentHideFn(); + } +} + +function showMenu({ + data, + id, + pageX, + pageY, +}: {| + data: Object, + id: string, + pageX: number, + pageY: number, +|}) { + const showFn = idToShowFnMap.get(id); + if (typeof showFn === 'function') { + currentHideFn = idToHideFnMap.get(id); + showFn({data, pageX, pageY}); + } +} + +function registerMenu(id: string, showFn: ShowFn, hideFn: HideFn) { + if (idToShowFnMap.has(id)) { + throw Error(`Context menu with id "${id}" already registered.`); + } + + idToShowFnMap.set(id, showFn); + idToHideFnMap.set(id, hideFn); + + return function unregisterMenu() { + idToShowFnMap.delete(id, showFn); + idToHideFnMap.delete(id, hideFn); + }; +} + +export const RegistryContext = createContext({ + hideMenu, + showMenu, + registerMenu, +}); diff --git a/packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js b/packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js new file mode 100644 index 0000000000000..54cdcdb0a2d75 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js @@ -0,0 +1,32 @@ +import {useContext, useEffect} from 'react'; +import {RegistryContext} from './Contexts'; + +export default function useContextMenu({data, id, ref}) { + const {showMenu} = useContext(RegistryContext); + + useEffect( + () => { + if (ref.current !== null) { + const handleContextMenu = event => { + event.preventDefault(); + event.stopPropagation(); + + const pageX = + event.pageX || (event.touches && event.touches[0].pageX); + const pageY = + event.pageY || (event.touches && event.touches[0].pageY); + + showMenu({data, id, pageX, pageY}); + }; + + const trigger = ref.current; + trigger.addEventListener('contextmenu', handleContextMenu); + + return () => { + trigger.removeEventListener('contextmenu', handleContextMenu); + }; + } + }, + [data, id, showMenu], + ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js b/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js index a59a0cad25950..f479d477f4255 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js @@ -8,7 +8,7 @@ */ import {copy} from 'clipboard-js'; -import React, {useCallback, useContext, useState} from 'react'; +import React, {useCallback, useContext, useRef, useState} from 'react'; import {BridgeContext, StoreContext} from '../context'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; @@ -18,6 +18,7 @@ import {InspectedElementContext} from './InspectedElementContext'; import KeyValue from './KeyValue'; import {serializeHooksForCopy} from '../utils'; import styles from './HooksTree.css'; +import useContextMenu from '../../ContextMenu/useContextMenu'; import {meta} from '../../../hydration'; import type {InspectPath} from './SelectedElement'; @@ -113,6 +114,22 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { [], ); + const contextMenuTriggerRef = useRef(null); + + useContextMenu({ + data: { + path: ['hooks', ...path], + type: + hook !== null && + typeof hook === 'object' && + hook.hasOwnProperty(meta.type) + ? hook[meta.type] + : typeof value, + }, + id: 'SelectedElement', + ref: contextMenuTriggerRef, + }); + if (hook.hasOwnProperty(meta.inspected)) { // This Hook is too deep and hasn't been hydrated. if (__DEV__) { @@ -169,6 +186,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { inspectPath={inspectPath} name="subHooks" path={path.concat(['subHooks'])} + pathRoot="hooks" value={subHooks} /> ); @@ -176,7 +194,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { if (isComplexDisplayValue) { return (
-
+
{subHooksView} @@ -200,7 +219,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { } else { return (
-
+
@@ -260,7 +280,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { } else { return (
-
+
) => void; + +export type CopyInspectedElementPath = ( + id: number, + path: Array, +) => void; + export type GetInspectedElementPath = ( id: number, path: Array, ) => void; + export type GetInspectedElement = ( id: number, ) => InspectedElementFrontend | null; type Context = {| + copyInspectedElementPath: CopyInspectedElementPath, getInspectedElementPath: GetInspectedElementPath, getInspectedElement: GetInspectedElement, + storeAsGlobal: StoreAsGlobal, |}; const InspectedElementContext = createContext(((null: any): Context)); @@ -88,6 +99,35 @@ function InspectedElementContextController({children}: Props) { const bridge = useContext(BridgeContext); const store = useContext(StoreContext); + const storeAsGlobalCount = useRef(1); + + // Ask the backend to store the value at the specified path as a global variable. + const storeAsGlobal = useCallback( + (id: number, path: Array) => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID !== null) { + bridge.send('storeAsGlobal', { + count: storeAsGlobalCount.current++, + id, + path, + rendererID, + }); + } + }, + [bridge, store], + ); + + // Ask the backend to copy the specified path to the clipboard. + const copyInspectedElementPath = useCallback( + (id: number, path: Array) => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID !== null) { + bridge.send('copyElementPath', {id, path, rendererID}); + } + }, + [bridge, store], + ); + // Ask the backend to fill in a "dehydrated" path; this will result in a "inspectedElement". const getInspectedElementPath = useCallback( (id: number, path: Array) => { @@ -287,9 +327,20 @@ function InspectedElementContextController({children}: Props) { ); const value = useMemo( - () => ({getInspectedElement, getInspectedElementPath}), + () => ({ + copyInspectedElementPath, + getInspectedElement, + getInspectedElementPath, + storeAsGlobal, + }), // InspectedElement is used to invalidate the cache and schedule an update with React. - [currentlyInspectedElement, getInspectedElement, getInspectedElementPath], + [ + copyInspectedElementPath, + currentlyInspectedElement, + getInspectedElement, + getInspectedElementPath, + storeAsGlobal, + ], ); return ( diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js index 7a5dbae571557..ca6616fdbe3ff 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js @@ -26,6 +26,7 @@ type Props = {| inspectPath?: InspectPath, label: string, overrideValueFn?: ?OverrideValueFn, + pathRoot: string, showWhenEmpty?: boolean, canAddEntries?: boolean, |}; @@ -35,6 +36,7 @@ export default function InspectedElementTree({ inspectPath, label, overrideValueFn, + pathRoot, canAddEntries = false, showWhenEmpty = false, }: Props) { @@ -88,6 +90,7 @@ export default function InspectedElementTree({ , value: any) => void; @@ -28,6 +29,7 @@ type KeyValueProps = {| name: string, overrideValueFn?: ?OverrideValueFn, path: Array, + pathRoot: string, value: any, |}; @@ -40,10 +42,12 @@ export default function KeyValue({ name, overrideValueFn, path, + pathRoot, value, }: KeyValueProps) { const [isOpen, setIsOpen] = useState(false); const prevIsOpenRef = useRef(isOpen); + const contextMenuTriggerRef = useRef(null); const isInspectable = value !== null && @@ -68,6 +72,20 @@ export default function KeyValue({ const toggleIsOpen = () => setIsOpen(prevIsOpen => !prevIsOpen); + useContextMenu({ + data: { + path: [pathRoot, ...path], + type: + value !== null && + typeof value === 'object' && + value.hasOwnProperty(meta.type) + ? value[meta.type] + : typeof value, + }, + id: 'SelectedElement', + ref: contextMenuTriggerRef, + }); + const dataType = typeof value; const isSimpleType = dataType === 'number' || @@ -95,7 +113,13 @@ export default function KeyValue({ const isEditable = typeof overrideValueFn === 'function' && !isReadOnly; children = ( -