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 + . + + + ); + } +}