diff --git a/src/backend/agent.js b/src/backend/agent.js index 185c1d34a1..4876921c78 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -28,10 +28,21 @@ export default class Agent extends EventEmitter { bridge.addListener('highlightElementInDOM', this.highlightElementInDOM); bridge.addListener('inspectElement', this.inspectElement); bridge.addListener('selectElement', this.selectElement); + bridge.addListener('startInspectingDOM', this.startInspectingDOM); + bridge.addListener('stopInspectingDOM', this.stopInspectingDOM); bridge.addListener('shutdown', this.shutdown); + } - // TODO Listen to bridge for things like selection. - // bridge.addListener('...'), this...); + getIDForNode(node: Object): number | null { + for (let rendererID in this._rendererInterfaces) { + // A renderer will throw if it can't find a fiber for the specified node. + try { + // $FlowFixMe + const renderer = this._rendererInterfaces[rendererID]; + return renderer.getFiberIDFromNative(node, true); + } catch (e) {} + } + return null; } highlightElementInDOM = ({ @@ -98,6 +109,20 @@ export default class Agent extends EventEmitter { this.emit('shutdown'); }; + startInspectingDOM = () => { + window.addEventListener('click', this._onClick); + window.addEventListener('mousedown', this._onMouseDown); + window.addEventListener('mouseover', this._onMouseOver); + }; + + stopInspectingDOM = () => { + hideOverlay(); + + window.removeEventListener('click', this._onClick); + window.removeEventListener('mousedown', this._onMouseDown); + window.removeEventListener('mouseover', this._onMouseOver); + }; + onHookOperations = (operations: Uint32Array) => { debug('onHookOperations', operations); @@ -107,4 +132,33 @@ export default class Agent extends EventEmitter { // this._bridge.send('operations', operations, [operations.buffer]); this._bridge.send('operations', operations); }; + + _onClick = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + this.stopInspectingDOM(); + this._bridge.send('stopInspectingDOM'); + }; + + _onMouseDown = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const target = ((event.target: any): HTMLElement); + const id = this.getIDForNode(target); + + if (id !== null) { + this._bridge.send('selectFiber', id); + } + }; + + _onMouseOver = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const target = ((event.target: any): HTMLElement); + + showOverlay(target, target.tagName.toLowerCase()); + }; } diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 0fcb9e3a46..8114e2a898 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -826,11 +826,18 @@ export function attach( return null; } } - function getReactElementFromNative(hostInstance) { - const fiber = renderer.findFiberByHostInstance(hostInstance); + function getFiberIDFromNative( + hostInstance, + findNearestUnfilteredAncestor = false + ) { + let fiber = renderer.findFiberByHostInstance(hostInstance); if (fiber != null) { - const primaryFiber = getPrimaryFiber(((fiber: any): Fiber)); - return primaryFiber; + if (findNearestUnfilteredAncestor) { + while (fiber !== null && shouldFilterFiber(fiber)) { + fiber = fiber.return; + } + } + return getFiberID(getPrimaryFiber(((fiber: any): Fiber))); } return null; } @@ -1150,8 +1157,8 @@ export function attach( } return { + getFiberIDFromNative, getNativeFromReactElement, - getReactElementFromNative, handleCommitFiberRoot, handleCommitFiberUnmount, inspectElement, diff --git a/src/backend/types.js b/src/backend/types.js index 64305c5952..f9dbbe0e49 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -46,7 +46,10 @@ export type ReactRenderer = { export type RendererInterface = { cleanup: () => void, getNativeFromReactElement?: ?(component: Fiber) => ?NativeType, - getReactElementFromNative?: ?(component: NativeType) => ?Fiber, + getFiberIDFromNative: ( + component: NativeType, + findNearestUnfilteredAncestor?: boolean + ) => number | null, handleCommitFiberRoot: (fiber: Object) => void, handleCommitFiberUnmount: (fiber: Object) => void, inspectElement: (id: number) => InspectedElement | null, diff --git a/src/devtools/store.js b/src/devtools/store.js index d968e1d4a2..511812430d 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -130,7 +130,7 @@ export default class Store extends EventEmitter { const element = this._idToElement.get(id); if (element == null) { - console.warn(`No element found with id "${id}`); + console.warn(`No element found with id "${id}"`); return null; } diff --git a/src/devtools/views/InspectHostNodesToggle.js b/src/devtools/views/InspectHostNodesToggle.js new file mode 100644 index 0000000000..2546059cd9 --- /dev/null +++ b/src/devtools/views/InspectHostNodesToggle.js @@ -0,0 +1,41 @@ +// @flow + +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { BridgeContext } from './context'; +import Toggle from './Toggle'; +import ButtonIcon from './ButtonIcon'; + +export default function InspectHostNodesToggle() { + const [isInspecting, setIsInspecting] = useState(false); + const bridge = useContext(BridgeContext); + + const handleChange = useCallback( + (isChecked: boolean) => { + setIsInspecting(isChecked); + + if (isChecked) { + bridge.send('startInspectingDOM'); + } else { + bridge.send('stopInspectingDOM'); + } + }, + [bridge] + ); + + useEffect(() => { + const onStopInspectingDOM = () => setIsInspecting(false); + bridge.addListener('stopInspectingDOM', onStopInspectingDOM); + return () => + bridge.removeListener('stopInspectingDOM', onStopInspectingDOM); + }, [bridge]); + + return ( + + + + ); +} diff --git a/src/devtools/views/Toggle.css b/src/devtools/views/Toggle.css new file mode 100644 index 0000000000..2173f2f7f0 --- /dev/null +++ b/src/devtools/views/Toggle.css @@ -0,0 +1,40 @@ +.ToggleDisabled, +.ToggleOn, +.ToggleOff { + border-radius: 0.25rem; + display: inline-flex; + align-items: center; + padding: 0.25rem; + cursor: pointer; + flex: 0 0 auto; +} + +.ToggleOff { + border: none; + background: var(--color-button-background); + color: var(--color-button); +} +.ToggleOff:hover { + background: var(--color-button-background-hover); + color: var(--color-button-hover); +} + +.ToggleOn, +.ToggleOn:active, +.ToggleOn:focus { + background: var(--color-button-background-focus); + color: var(--color-button-focus); + outline: none; +} + +.ToggleDisabled { + background: var(--color-button-background); + color: var(--color-button-disabled); + cursor: default; +} + +.Input { + width: 0; + margin: 0; + opacity: 0; +} diff --git a/src/devtools/views/Toggle.js b/src/devtools/views/Toggle.js new file mode 100644 index 0000000000..43803f25bc --- /dev/null +++ b/src/devtools/views/Toggle.js @@ -0,0 +1,50 @@ +// @flow + +import React, { useCallback } from 'react'; + +import styles from './Toggle.css'; + +type Props = { + children: React$Node, + className?: string, + isChecked: boolean, + isDisabled?: boolean, + onChange: (isChecked: boolean) => void, +}; + +export default function Toggle({ + children, + className = '', + isDisabled = false, + isChecked, + onChange, +}: Props) { + let defaultClassName; + if (isDisabled) { + defaultClassName = styles.ToggleDisabled; + } else if (isChecked) { + defaultClassName = styles.ToggleOn; + } else { + defaultClassName = styles.ToggleOff; + } + + const handleChange = useCallback( + ({ target }) => { + onChange(target.checked); + }, + [onChange] + ); + + return ( + + ); +} diff --git a/src/devtools/views/Tree.js b/src/devtools/views/Tree.js index bb2bd0589d..1416d8ea81 100644 --- a/src/devtools/views/Tree.js +++ b/src/devtools/views/Tree.js @@ -11,9 +11,8 @@ import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList } from 'react-window'; import { TreeContext } from './TreeContext'; import { SettingsContext } from './SettingsContext'; -import Button from './Button'; -import ButtonIcon from './ButtonIcon'; import Element from './Element'; +import InspectHostNodesToggle from './InspectHostNodesToggle'; import OwnersStack from './OwnersStack'; import SearchInput from './SearchInput'; @@ -85,12 +84,7 @@ export default function Tree(props: Props) {
{ownerStack.length > 0 ? : } - +
diff --git a/src/devtools/views/TreeContext.js b/src/devtools/views/TreeContext.js index 4421af5779..9d743e5358 100644 --- a/src/devtools/views/TreeContext.js +++ b/src/devtools/views/TreeContext.js @@ -26,7 +26,7 @@ import React, { useReducer, } from 'react'; import { createRegExp } from './utils'; -import { StoreContext } from './context'; +import { BridgeContext, StoreContext } from './context'; import Store from '../store'; import type { Element } from 'src/devtools/types'; @@ -470,7 +470,9 @@ function reduceOwnersState(store: Store, state: State, action: Action): State { // TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists. function TreeContextController({ children }: {| children: React$Node |}) { + const bridge = useContext(BridgeContext); const store = useContext(StoreContext); + const initialRevision = useMemo(() => store.revision, [store]); // This reducer is created inline because it needs access to the Store. @@ -596,6 +598,14 @@ function TreeContextController({ children }: {| children: React$Node |}) { [state] ); + // Listen for host element selections. + useEffect(() => { + const handleSelectFiber = (id: number) => + dispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: id }); + bridge.addListener('selectFiber', handleSelectFiber); + return () => bridge.removeListener('selectFiber', handleSelectFiber); + }, [bridge, dispatch]); + // Mutations to the underlying tree may impact this context (e.g. search results, selection state). useEffect(() => { const handleStoreMutated = ([ @@ -622,7 +632,7 @@ function TreeContextController({ children }: {| children: React$Node |}) { store.addListener('mutated', handleStoreMutated); return () => store.removeListener('mutated', handleStoreMutated); - }, [store]); + }, [dispatch, store]); return {children}; }