From 48934e25a69c394b0c9c01770f0a6b19665e7217 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 13 Dec 2019 18:47:05 -0800 Subject: [PATCH 01/10] Added rudimentary context menu hook and menu UI --- .../src/devtools/ContextMenu/ContextMenu.css | 7 + .../src/devtools/ContextMenu/ContextMenu.js | 121 ++++++++++++++++++ .../devtools/ContextMenu/ContextMenuItem.css | 22 ++++ .../devtools/ContextMenu/ContextMenuItem.js | 29 +++++ .../src/devtools/ContextMenu/Contexts.js | 55 ++++++++ .../devtools/ContextMenu/useContextMenu.js | 32 +++++ 6 files changed, 266 insertions(+) create mode 100644 packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.css create mode 100644 packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js create mode 100644 packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.css create mode 100644 packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js create mode 100644 packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js create mode 100644 packages/react-devtools-shared/src/devtools/ContextMenu/useContextMenu.js 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..f71f158abce5b --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenu.js @@ -0,0 +1,121 @@ +import React, { + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import {createPortal} from 'react-dom'; +import {DataContext, RegistryContext} from './Contexts'; + +import styles from './ContextMenu.css'; + +function respositionToFit(element, pageX, pageY) { + if (element !== null) { + if (pageY + element.offsetHeight >= window.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 >= window.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 containerRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + containerRef.current = document.createElement('div'); + document.body.appendChild(containerRef.current); + return () => { + document.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 hide = event => { + if (!menu.contains(event.target)) { + setState(HIDDEN_STATE); + } + }; + + document.addEventListener('mousedown', hide); + document.addEventListener('touchstart', hide); + document.addEventListener('keydown', hide); + + window.addEventListener('resize', hide); + + respositionToFit(menu, state.pageX, state.pageY); + + return () => { + document.removeEventListener('mousedown', hide); + document.removeEventListener('touchstart', hide); + document.removeEventListener('keydown', hide); + + window.removeEventListener('resize', hide); + }; + }, + [state], + ); + + if (!state.isVisible) { + return null; + } else { + return createPortal( +
+ + {children} + +
, + 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..eaf9afcdf4245 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem.js @@ -0,0 +1,29 @@ +import React, {useContext} from 'react'; +import {DataContext, 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 data = useContext(DataContext); + const {hideMenu} = useContext(RegistryContext); + + const handleClick = event => { + onClick(data); + 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..9bc9b0ffb2530 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/ContextMenu/Contexts.js @@ -0,0 +1,55 @@ +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, +}); + +export const DataContext = createContext(null); 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..380c4469bac66 --- /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.clientX || (event.touches && event.touches[0].pageX); + const pageY = + event.clientY || (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], + ); +} From 4766503de495f68dc7df0e7f2861a576a3dac135 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 14 Dec 2019 09:53:17 -0800 Subject: [PATCH 02/10] Added backend support for copying a value at a specific path for the inspected element --- .../react-devtools-shared/src/backend/agent.js | 16 ++++++++++++++++ .../src/backend/legacy/renderer.js | 15 +++++++++++++-- .../src/backend/renderer.js | 17 ++++++++++++++++- .../react-devtools-shared/src/backend/types.js | 1 + .../react-devtools-shared/src/backend/utils.js | 13 +++++++++++++ packages/react-devtools-shared/src/bridge.js | 6 ++++++ 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 5161b434c5bad..8142c0c26d611 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -55,6 +55,12 @@ type ElementAndRendererID = {| rendererID: number, |}; +type CopyElementParams = {| + id: number, + path: Array, + rendererID: number, +|}; + type InspectElementParams = {| id: number, path?: Array, @@ -126,6 +132,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); @@ -173,6 +180,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, diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 3e7232874504b..5bc384bd0eba5 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -7,6 +7,7 @@ * @flow */ +import {copy} from 'clipboard-js'; import { ElementTypeClass, ElementTypeFunction, @@ -15,8 +16,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, copyWithSet, safeSerialize} from '../utils'; +import {getDisplayName, getInObject} from 'react-devtools-shared/src/utils'; import { __DEBUG__, TREE_OPERATION_ADD, @@ -649,6 +650,15 @@ export function attach( } } + function copyElementPath(id: number, path: Array): void { + const inspectedElement = inspectElementRaw(id); + if (inspectedElement !== null) { + const value = getInObject(inspectedElement, path); + + copy(safeSerialize(value)); + } + } + function inspectElement( id: number, path?: Array, @@ -927,6 +937,7 @@ export function attach( return { cleanup, + copyElementPath, flushInitialOperations, getBestMatchForTrackedPath, getFiberIDForNative: getInternalIDForNative, diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index f62255579054e..204346acd5247 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -8,6 +8,7 @@ */ import {gte} from 'semver'; +import {copy} from 'clipboard-js'; import { ComponentFilterDisplayName, ComponentFilterElementType, @@ -34,7 +35,7 @@ import { utfEncodeString, } from 'react-devtools-shared/src/utils'; import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; -import {cleanForBridge, copyWithSet} from './utils'; +import {cleanForBridge, copyWithSet, safeSerialize} from './utils'; import { __DEBUG__, SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, @@ -2488,6 +2489,19 @@ export function attach( } } + function copyElementPath(id: number, path: Array): void { + const isCurrent = isMostRecentlyInspectedElementCurrent(id); + + if (isCurrent) { + const value = getInObject( + ((mostRecentlyInspectedElement: any): InspectedElement), + path, + ); + + copy(safeSerialize(value)); + } + } + function inspectElement( id: number, path?: Array, @@ -3129,6 +3143,7 @@ export function attach( return { cleanup, + copyElementPath, findNativeNodesForFiberID, flushInitialOperations, getBestMatchForTrackedPath, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 044c153cceeab..08afa9d40a18e 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, diff --git a/packages/react-devtools-shared/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index f6a2494ba7c79..886c11cf7cbed 100644 --- a/packages/react-devtools-shared/src/backend/utils.js +++ b/packages/react-devtools-shared/src/backend/utils.js @@ -52,3 +52,16 @@ export function copyWithSet( updated[key] = copyWithSet(obj[key], path, value, index + 1); return updated; } + +export function safeSerialize(data: any): string { + const cache = new Set(); + 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..ffc052a7e928a 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -51,6 +51,11 @@ type OverrideSuspense = {| forceFallback: boolean, |}; +type CopyElementPathParams = {| + ...ElementAndRendererID, + path: Array, +|}; + type InspectElementParams = {| ...ElementAndRendererID, path?: Array, @@ -95,6 +100,7 @@ type BackendEvents = {| type FrontendEvents = {| clearNativeElementHighlight: [], + copyElementPath: [CopyElementPathParams], getOwnersList: [ElementAndRendererID], getProfilingData: [{|rendererID: RendererID|}], getProfilingStatus: [], From bd625756c47ea07a78f0f12ef1b192ea0b7512b5 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 14 Dec 2019 15:53:19 -0800 Subject: [PATCH 03/10] Added backend support for storing a value (at a specified path) as a global variable --- .../react-devtools-shared/src/backend/agent.js | 16 ++++++++++++++++ .../src/backend/legacy/renderer.js | 13 +++++++++++++ .../src/backend/renderer.js | 17 +++++++++++++++++ .../react-devtools-shared/src/backend/types.js | 1 + packages/react-devtools-shared/src/bridge.js | 6 ++++++ 5 files changed, 53 insertions(+) diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 8142c0c26d611..eb4272019a478 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -55,6 +55,12 @@ type ElementAndRendererID = {| rendererID: number, |}; +type StoreAsGlobalParams = {| + id: number, + path: Array, + rendererID: number, +|}; + type CopyElementParams = {| id: number, path: Array, @@ -147,6 +153,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, @@ -425,6 +432,15 @@ export default class Agent extends EventEmitter<{| this._bridge.send('profilingStatus', this._isProfiling); }; + storeAsGlobal = ({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); + } + }; + 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- diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 5bc384bd0eba5..a4e844a1381af 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -650,6 +650,18 @@ export function attach( } } + function storeAsGlobal(id: number, path: Array): void { + const inspectedElement = inspectElementRaw(id); + if (inspectedElement !== null) { + const value = getInObject(inspectedElement, path); + + window.$reactTemp = value; + + console.log('$reactTemp'); + console.log(value); + } + } + function copyElementPath(id: number, path: Array): void { const inspectedElement = inspectElementRaw(id); if (inspectedElement !== null) { @@ -964,6 +976,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 204346acd5247..73ee1caf11968 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -2489,6 +2489,22 @@ export function attach( } } + function storeAsGlobal(id: number, path: Array): void { + const isCurrent = isMostRecentlyInspectedElementCurrent(id); + + if (isCurrent) { + const value = getInObject( + ((mostRecentlyInspectedElement: any): InspectedElement), + path, + ); + + window.$reactTemp = value; + + console.log('$reactTemp'); + console.log(value); + } + } + function copyElementPath(id: number, path: Array): void { const isCurrent = isMostRecentlyInspectedElementCurrent(id); @@ -3167,6 +3183,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 08afa9d40a18e..bca75000d3273 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -257,6 +257,7 @@ export type RendererInterface = { setTrackedPath: (path: Array | null) => void, startProfiling: (recordChangeDescriptions: boolean) => void, stopProfiling: () => void, + storeAsGlobal: (id: number, path: Array) => void, updateComponentFilters: (componentFilters: Array) => void, }; diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index ffc052a7e928a..52bbd4bc15935 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -61,6 +61,11 @@ type InspectElementParams = {| path?: Array, |}; +type StoreAsGlobalParams = {| + ...ElementAndRendererID, + path: Array, +|}; + type NativeStyleEditor_RenameAttributeParams = {| ...ElementAndRendererID, oldName: string, @@ -121,6 +126,7 @@ type FrontendEvents = {| startProfiling: [boolean], stopInspectingNative: [boolean], stopProfiling: [], + storeAsGlobal: [StoreAsGlobalParams], updateAppendComponentStack: [boolean], updateComponentFilters: [Array], viewElementSource: [ElementAndRendererID], From 1cf6b2f2567bb46ddf16d97bc9a1a1addac7f930 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 14 Dec 2019 16:05:33 -0800 Subject: [PATCH 04/10] Added special casing to enable copying undefined/unserializable values to the clipboard --- .../src/backend/legacy/renderer.js | 7 ++----- .../react-devtools-shared/src/backend/renderer.js | 13 ++++++------- packages/react-devtools-shared/src/backend/utils.js | 6 ++++++ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index a4e844a1381af..2d3a27113eb76 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -7,7 +7,6 @@ * @flow */ -import {copy} from 'clipboard-js'; import { ElementTypeClass, ElementTypeFunction, @@ -16,7 +15,7 @@ import { ElementTypeOtherOrUnknown, } from 'react-devtools-shared/src/types'; import {getUID, utfEncodeString, printOperationsArray} from '../../utils'; -import {cleanForBridge, copyWithSet, safeSerialize} from '../utils'; +import {cleanForBridge, copyToClipboard, copyWithSet} from '../utils'; import {getDisplayName, getInObject} from 'react-devtools-shared/src/utils'; import { __DEBUG__, @@ -665,9 +664,7 @@ export function attach( function copyElementPath(id: number, path: Array): void { const inspectedElement = inspectElementRaw(id); if (inspectedElement !== null) { - const value = getInObject(inspectedElement, path); - - copy(safeSerialize(value)); + copyToClipboard(getInObject(inspectedElement, path)); } } diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 73ee1caf11968..417655b471a89 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -8,7 +8,6 @@ */ import {gte} from 'semver'; -import {copy} from 'clipboard-js'; import { ComponentFilterDisplayName, ComponentFilterElementType, @@ -35,7 +34,7 @@ import { utfEncodeString, } from 'react-devtools-shared/src/utils'; import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; -import {cleanForBridge, copyWithSet, safeSerialize} from './utils'; +import {cleanForBridge, copyToClipboard, copyWithSet} from './utils'; import { __DEBUG__, SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, @@ -2509,12 +2508,12 @@ export function attach( const isCurrent = isMostRecentlyInspectedElementCurrent(id); if (isCurrent) { - const value = getInObject( - ((mostRecentlyInspectedElement: any): InspectedElement), - path, + copyToClipboard( + getInObject( + ((mostRecentlyInspectedElement: any): InspectedElement), + path, + ), ); - - copy(safeSerialize(value)); } } diff --git a/packages/react-devtools-shared/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index 886c11cf7cbed..73fb2e86cf6b5 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 = safeSerialize(value); + copy(safeToCopy === undefined ? 'undefined' : safeToCopy); +} + export function copyWithSet( obj: Object | Array, path: Array, From dd1a02ed35693e398b27d62583702c6af344d4d1 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 14 Dec 2019 16:12:37 -0800 Subject: [PATCH 05/10] Added copy and store-as-global context menu options to selected element props panel --- .../devtools/views/Components/HooksTree.js | 20 ++- .../Components/InspectedElementContext.js | 47 +++++- .../views/Components/InspectedElementTree.js | 3 + .../src/devtools/views/Components/KeyValue.js | 31 +++- .../views/Components/SelectedElement.css | 4 + .../views/Components/SelectedElement.js | 151 +++++++++++------- .../src/devtools/views/Icon.js | 17 ++ .../views/Settings/SettingsContext.js | 10 ++ .../src/devtools/views/root.css | 12 ++ .../InspectableElements/CircularReferences.js | 27 ++++ .../InspectableElements.js | 2 + 11 files changed, 258 insertions(+), 66 deletions(-) create mode 100644 packages/react-devtools-shell/src/app/InspectableElements/CircularReferences.js 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..1d09ec84e8cff 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,14 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { [], ); + const contextMenuTriggerRef = useRef(null); + + useContextMenu({ + data: ['hooks', ...path], + id: 'SelectedElement', + ref: contextMenuTriggerRef, + }); + if (hook.hasOwnProperty(meta.inspected)) { // This Hook is too deep and hasn't been hydrated. if (__DEV__) { @@ -169,6 +178,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { inspectPath={inspectPath} name="subHooks" path={path.concat(['subHooks'])} + pathRoot="hooks" value={subHooks} /> ); @@ -176,7 +186,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { if (isComplexDisplayValue) { return (
-
+
{subHooksView} @@ -200,7 +211,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { } else { return (
-
+
@@ -260,7 +272,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 +98,28 @@ function InspectedElementContextController({children}: Props) { const bridge = useContext(BridgeContext); const store = useContext(StoreContext); + // 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', {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 +319,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,12 @@ export default function KeyValue({ const toggleIsOpen = () => setIsOpen(prevIsOpen => !prevIsOpen); + useContextMenu({ + data: [pathRoot, ...path], + id: 'SelectedElement', + ref: contextMenuTriggerRef, + }); + const dataType = typeof value; const isSimpleType = dataType === 'number' || @@ -95,7 +105,13 @@ export default function KeyValue({ const isEditable = typeof overrideValueFn === 'function' && !isReadOnly; children = ( -