/** * 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, ElementSearchResultSet, } 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, inAlignmentMode?: boolean, selectedElement: ?ElementID, selectedAXElement: ?ElementID, onSelect: (ids: ?ElementID) => void, onDataValueChanged: (path: Array, value: any) => void, setPersistedState: (state: $Shape) => void, persistedState: PersistedState, searchResults: ?ElementSearchResultSet, }; 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; }; focused = () => { if (!this.props.ax) { return null; } // $FlowFixMe: Object.values returns Array const elements: Array = Object.values( this.props.persistedState.AXelements, ); return elements.find(i => i?.data?.Accessibility?.['accessibility-focused']) ?.id; }; getAXContextMenuExtensions = () => this.props.ax ? [ { label: 'Focus', click: (id: ElementID) => { this.props.client.call('onRequestAXFocus', {id}); }, }, ] : []; 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, children: Array}>, }) => { const ids = nodes .map(n => [n.id, ...(n.children || [])]) .reduce((acc, cv) => acc.concat(cv), []); this.invalidate(ids); }, ); this.props.client.subscribe( this.call().SELECT, ({path}: {path: Array}) => { this.getAndExpandPath(path); }, ); if (this.props.ax) { this.props.client.subscribe('axFocusEvent', () => { // update all nodes, to find new focused node this.getNodes(Object.keys(this.props.persistedState.AXelements), { force: true, ax: true, }); }); } } 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); } } invalidate(ids: Array): Promise> { if (ids.length === 0) { return Promise.resolve([]); } return this.getNodes(ids, {}).then((elements: Array) => { const children = elements .filter((element: Element) => this.elements()[element.id]?.expanded) .map((element: Element) => element.children) .reduce((acc, val) => acc.concat(val), []); return this.invalidate(children); }); } 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, {}).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, ): Promise> { if (!this.elements()[id]) { await this.getNodes([id], options); } this.updateElement(id, {expanded: true}); return this.getNodes(this.elements()[id].children, options); } getNodes( ids: Array = [], options: GetNodesOptions, ): 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 => this.updateElement(e.id, e)); return elements; }); } else { return Promise.resolve([]); } } getAndExpandPath(path: Array) { return Promise.all(path.map(id => this.getChildren(id, {}))).then(() => { this.onElementSelected(path[path.length - 1]); }); } 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, isAlignmentMode: this.props.inAlignmentMode, }), ); 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; } }