From c21875e1688b84c0f7e0065a1e16479ce173753b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20B=C3=BCchele?= Date: Mon, 18 Feb 2019 04:53:54 -0800 Subject: [PATCH] refactoring Summary: This is refactoring the layout inspector. The old layout inspector was a single file with more than 1200 LOC which was really hard to debug and extend. This aims for splitting it up into smaller, easier to maintain components. This version of the layout inspector only shows the view hierarchy for the regular view tree and the a11y tree. Additional features are added in stacked diffs. Reviewed By: jknoxville Differential Revision: D14100536 fbshipit-source-id: ca5e22dbb6ed9e34ce208a2a699ebfeb083904ad --- src/index.js | 1 + src/plugins/layout/layout2/Inspector.js | 212 ++++++++++++++++++++++++ src/plugins/layout/layout2/index.js | 132 +++++++++++++++ 3 files changed, 345 insertions(+) create mode 100644 src/plugins/layout/layout2/Inspector.js create mode 100644 src/plugins/layout/layout2/index.js diff --git a/src/index.js b/src/index.js index 0fa9ba30c..65f0452a1 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,7 @@ export { FlipperPlugin, FlipperDevicePlugin, } from './plugin.js'; +export type {PluginClient} from './plugin.js'; export {clipboard} from 'electron'; export * from './fb-stubs/constants.js'; export * from './utils/createPaste.js'; diff --git a/src/plugins/layout/layout2/Inspector.js b/src/plugins/layout/layout2/Inspector.js new file mode 100644 index 000000000..28e2c3967 --- /dev/null +++ b/src/plugins/layout/layout2/Inspector.js @@ -0,0 +1,212 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type {ElementID, Element, PluginClient} from 'flipper'; +import {ElementsInspector} from 'flipper'; +import {Component} from 'react'; +import debounce from 'lodash.debounce'; + +import type {PersistedState} from './'; + +type GetNodesOptions = { + force?: boolean, + ax?: boolean, + forAccessibilityEvent?: boolean, +}; + +type Props = { + ax?: boolean, + client: PluginClient, + showsSidebar: boolean, + selectedElement: ?ElementID, + selectedAXElement: ?ElementID, + onSelect: (ids: ?ElementID) => void, + onDataValueChanged: (path: Array, value: any) => void, + setPersistedState: (state: $Shape) => void, + persistedState: PersistedState, +}; + +export default class Inspector extends Component { + call() { + return { + GET_ROOT: this.props.ax ? 'getAXRoot' : 'getRoot', + INVALIDATE: this.props.ax ? 'invalidateAX' : 'invalidate', + GET_NODES: this.props.ax ? 'getAXNodes' : 'getNodes', + SET_HIGHLIGHTED: 'setHighlighted', + SELECT: this.props.ax ? 'selectAX' : 'select', + }; + } + + selected = () => { + return this.props.ax + ? this.props.selectedAXElement + : this.props.selectedElement; + }; + + root = () => { + return this.props.ax + ? this.props.persistedState.rootAXElement + : this.props.persistedState.rootElement; + }; + + elements = () => { + return this.props.ax + ? this.props.persistedState.AXelements + : this.props.persistedState.elements; + }; + + componentDidMount() { + this.props.client.call(this.call().GET_ROOT).then((root: Element) => { + this.props.setPersistedState({ + [this.props.ax ? 'rootAXElement' : 'rootElement']: root.id, + }); + this.updateElement(root.id, {...root, expanded: true}); + this.performInitialExpand(root); + }); + + this.props.client.subscribe( + this.call().INVALIDATE, + ({nodes}: {nodes: Array<{id: ElementID}>}) => { + this.getNodes(nodes.map(n => n.id), {}); + }, + ); + } + + componentDidUpdate(prevProps: Props) { + const {ax, selectedElement, selectedAXElement} = this.props; + + if ( + ax && + selectedElement !== prevProps.selectedElement && + selectedElement + ) { + // selected element changed, find linked AX element + const linkedAXNode: ?ElementID = this.props.persistedState.elements[ + selectedElement + ]?.extraInfo?.linkedAXNode; + this.props.onSelect(linkedAXNode); + } else if ( + !ax && + selectedAXElement !== prevProps.selectedAXElement && + selectedAXElement + ) { + // selected AX element changed, find linked element + // $FlowFixMe Object.values retunes mixed type + const linkedNode: ?Element = Object.values( + this.props.persistedState.elements, + // $FlowFixMe it's an Element not mixed + ).find((e: Element) => e.extraInfo?.linkedAXNode === selectedAXElement); + this.props.onSelect(linkedNode?.id); + } + } + + updateElement(id: ElementID, data: Object) { + this.props.setPersistedState({ + [this.props.ax ? 'AXelements' : 'elements']: { + ...this.elements(), + [id]: { + ...this.elements()[id], + ...data, + }, + }, + }); + } + + // When opening the inspector for the first time, expand all elements that + // contain only 1 child recursively. + async performInitialExpand(element: Element): Promise { + if (!element.children.length) { + // element has no children so we're as deep as we can be + return; + } + return this.getChildren(element.id, {}, true).then( + (elements: Array) => { + if (element.children.length >= 2) { + // element has two or more children so we can stop expanding + return; + } + return this.performInitialExpand(this.elements()[element.children[0]]); + }, + ); + } + + async getChildren( + id: ElementID, + options: GetNodesOptions, + expanded?: boolean, + ): Promise> { + if (!this.elements()[id]) { + await this.getNodes([id], options, expanded); + } + return this.getNodes(this.elements()[id].children, options, expanded); + } + + getNodes( + ids: Array = [], + options: GetNodesOptions, + expanded?: boolean, + ): Promise> { + const {forAccessibilityEvent} = options; + + if (ids.length > 0) { + return this.props.client + .call(this.call().GET_NODES, { + ids, + forAccessibilityEvent, + selected: false, + }) + .then(({elements}) => { + elements.forEach(e => { + if (typeof expanded === 'boolean') { + e.expanded = expanded; + } + this.updateElement(e.id, e); + }); + return elements; + }); + } else { + return Promise.resolve([]); + } + } + + onElementSelected = debounce((selectedKey: ElementID) => { + this.onElementHovered(selectedKey); + this.props.onSelect(selectedKey); + }); + + onElementHovered = debounce((key: ?ElementID) => + this.props.client.call(this.call().SET_HIGHLIGHTED, { + id: key, + }), + ); + + onElementExpanded = (id: ElementID, deep: boolean) => { + const expanded = !this.elements()[id].expanded; + this.updateElement(id, {expanded}); + if (expanded) { + this.getChildren(id, {}).then(children => { + if (deep) { + children.forEach(child => this.onElementExpanded(child.id, deep)); + } + }); + } + }; + + render() { + return this.root() ? ( + + ) : null; + } +} diff --git a/src/plugins/layout/layout2/index.js b/src/plugins/layout/layout2/index.js new file mode 100644 index 000000000..93329228d --- /dev/null +++ b/src/plugins/layout/layout2/index.js @@ -0,0 +1,132 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type {ElementID, Element} from 'flipper'; + +import { + FlexColumn, + FlexRow, + FlipperPlugin, + Toolbar, + Sidebar, + Link, + Glyph, +} from 'flipper'; +import Inspector from './Inspector'; +import ToolbarIcon from './ToolbarIcon'; + +type State = {| + init: boolean, + inAXMode: boolean, + selectedElement: ?ElementID, + selectedAXElement: ?ElementID, +|}; + +export type PersistedState = {| + rootElement: ?ElementID, + rootAXElement: ?ElementID, + elements: {[key: ElementID]: Element}, + AXelements: {[key: ElementID]: Element}, +|}; + +export default class Layout extends FlipperPlugin { + static defaultPersistedState = { + rootElement: null, + rootAXElement: null, + elements: {}, + AXelements: {}, + }; + + state = { + init: false, + inAXMode: false, + selectedElement: null, + selectedAXElement: null, + }; + + componentDidMount() { + this.props.setPersistedState(Layout.defaultPersistedState); + } + + init() { + this.setState({init: true}); + } + + onToggleAXMode = () => { + this.setState({inAXMode: !this.state.inAXMode}); + }; + + onDataValueChanged = (path: Array, value: any) => { + const id = this.state.inAXMode + ? this.state.selectedAXElement + : this.state.selectedElement; + this.client.call('setData', { + id, + path, + value, + ax: this.state.inAXMode, + }); + }; + + render() { + const inspectorProps = { + client: this.client, + selectedElement: this.state.selectedElement, + selectedAXElement: this.state.selectedAXElement, + setPersistedState: this.props.setPersistedState, + persistedState: this.props.persistedState, + onDataValueChanged: this.onDataValueChanged, + }; + + return ( + + {this.state.init && ( + <> + + {this.realClient.query.os === 'Android' && ( + + )} + + + + this.setState({selectedElement})} + showsSidebar={!this.state.inAXMode} + /> + {this.state.inAXMode && ( + + + this.setState({selectedAXElement}) + } + showsSidebar={true} + ax + /> + + )} + + + )} + +   + Version 2.0:  Provide feedback about this plugin + in our  + + feedback group + . + + + ); + } +}