desktop/plugins/public/layout/Inspector.tsx (424 lines of code) (raw):
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/
import {
ElementID,
Element,
PluginClient,
ElementsInspector,
ElementSearchResultSet,
} from 'flipper';
import {debounce} from 'lodash';
import {Component} from 'react';
import {PersistedState, ElementMap} from './';
import React from 'react';
import MultipleSelectorSection from './MultipleSelectionSection';
import {Layout} from 'flipper-plugin';
type GetNodesOptions = {
force?: boolean;
ax?: boolean;
forAccessibilityEvent?: boolean;
};
export type ElementSelectorNode = {[id: string]: ElementSelectorNode};
export type ElementSelectorData = {
leaves: Array<ElementID>;
tree: ElementSelectorNode;
elements: ElementMap;
};
type Props = {
ax?: boolean;
client: PluginClient;
showsSidebar: boolean;
inAlignmentMode?: boolean;
selectedElement: ElementID | null | undefined;
selectedAXElement: ElementID | null | undefined;
onSelect: (ids: ElementID | null | undefined) => void;
setPersistedState: (state: Partial<PersistedState>) => void;
persistedState: PersistedState;
searchResults: ElementSearchResultSet | null;
};
type State = {
elementSelector: ElementSelectorData | null;
axElementSelector: ElementSelectorData | null;
};
export default class Inspector extends Component<Props, State> {
state: State = {elementSelector: null, axElementSelector: null};
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',
INVALIDATE_WITH_DATA: this.props.ax
? 'invalidateWithDataAX'
: 'invalidateWithData',
};
}
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;
}
const elements: Array<Element> = Object.values(
this.props.persistedState.AXelements,
);
const focusedElement = elements.find((i) =>
Boolean(
i.data.Accessibility && i.data.Accessibility['accessibility-focused'],
),
);
return focusedElement ? focusedElement.id : null;
};
getAXContextMenuExtensions = () =>
this.props.ax
? [
{
label: 'Focus',
click: (id: ElementID) => {
if (this.props.client.isConnected) {
this.props.client.call('onRequestAXFocus', {id});
}
},
},
]
: [];
componentDidMount() {
if (!this.props.client.isConnected) {
return;
}
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);
})
.catch((e) => console.error('[layout] GET_ROOT failed:', e));
this.props.client.subscribe(
this.call().INVALIDATE,
({
nodes,
}: {
nodes: Array<{id: ElementID; children: Array<ElementID>}>;
}) => {
const ids = nodes
.map((n) => [n.id, ...(n.children || [])])
.reduce((acc, cv) => acc.concat(cv), []);
this.invalidate(ids);
},
);
this.props.client.subscribe(
this.call().INVALIDATE_WITH_DATA,
(obj: {nodes: Array<Element>}) => {
const {nodes} = obj;
this.invalidateWithData(nodes);
},
);
this.props.client.subscribe(
this.call().SELECT,
async ({
path,
tree,
}: {
path?: Array<ElementID>;
tree?: ElementSelectorNode;
}) => {
if (path) {
this.getAndExpandPath(path);
}
if (tree) {
const leaves = this.getElementLeaves(tree);
const elementArray = await this.getNodes(leaves, {});
const elements = leaves.reduce(
(acc, cur, idx) => ({...acc, [cur]: elementArray[idx]}),
{},
);
if (this.props.ax) {
this.setState({axElementSelector: {tree, leaves, elements}});
} else {
this.setState({elementSelector: {tree, leaves, elements}});
}
}
},
);
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 &&
selectedElement !== prevProps.selectedElement
) {
// selected element in non-AX tree changed, find linked element in AX tree
const newlySelectedElem =
this.props.persistedState.elements[selectedElement];
if (newlySelectedElem) {
this.props.onSelect(
newlySelectedElem.extraInfo
? newlySelectedElem.extraInfo.linkedNode
: null,
);
}
} else if (
!ax &&
selectedAXElement &&
selectedAXElement !== prevProps.selectedAXElement
) {
// selected element in AX tree changed, find linked element in non-AX tree
const newlySelectedAXElem =
this.props.persistedState.AXelements[selectedAXElement];
if (newlySelectedAXElem) {
this.props.onSelect(
newlySelectedAXElem.extraInfo
? newlySelectedAXElem.extraInfo.linkedNode
: null,
);
}
}
}
invalidateWithData(elements: Array<Element>): void {
if (elements.length === 0) {
return;
}
const updatedElements: ElementMap = elements.reduce(
(acc: ElementMap, element: Element) => {
acc[element.id] = {
...element,
expanded: this.elements()[element.id]
? this.elements()[element.id].expanded
: false,
};
return acc;
},
{},
);
this.props.setPersistedState({
[this.props.ax ? 'AXelements' : 'elements']: {
...this.elements(),
...updatedElements,
},
});
}
async invalidate(ids: Array<ElementID>): Promise<Array<Element>> {
if (ids.length === 0) {
return Promise.resolve([]);
}
const elements = await this.getNodes(ids, {});
const children = elements
.filter(
(element: Element) =>
this.elements()[element.id] && 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 | undefined): Promise<void> {
if (!element || !element.children || !element.children.length) {
// element has no children so we're as deep as we can be
return;
}
return this.getChildren(element.id, {}).then(() => {
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<Array<Element>> {
if (!this.elements()[id]) {
await this.getNodes([id], options);
}
this.updateElement(id, {expanded: true});
const element: Element | undefined = this.elements()[id];
return this.getNodes((element && element.children) || [], options);
}
async getNodes(
ids: Array<ElementID> = [],
options: GetNodesOptions,
): Promise<Array<Element>> {
if (ids.length > 0 && this.props.client.isConnected) {
const {forAccessibilityEvent} = options;
const {elements}: {elements: Array<Element>} = await this.props.client
.call(this.call().GET_NODES, {
ids,
forAccessibilityEvent,
selected: false,
})
.catch((e) => {
console.error(`[Layout] Failed to fetch nodes from app:`, e);
return {elements: []};
});
if (!elements) {
return [];
}
elements.forEach((e) => this.updateElement(e.id, e));
return elements;
} else {
return [];
}
}
async getAndExpandPath(path: Array<ElementID>) {
await Promise.all(path.map((id) => this.getChildren(id, {})));
for (const id of path) {
this.updateElement(id, {expanded: true});
}
this.onElementSelected()(path[path.length - 1]);
}
getElementLeaves(tree: ElementSelectorNode): Array<ElementID> {
return tree
? Object.entries(tree).reduce(
(
currLeafNode: Array<ElementID>,
[id, children]: [ElementID, ElementSelectorNode],
): Array<ElementID> =>
currLeafNode.concat(
Object.keys(children).length > 0
? this.getElementLeaves(children)
: [id],
),
[],
)
: [];
}
/// Return path from given tree structure and id if id is not null; otherwise return any path
getPathForNode(
tree: ElementSelectorNode,
nodeID: ElementID | null,
): Array<ElementID> | null {
for (const node in tree) {
if (
node === nodeID ||
(nodeID === null && Object.keys(tree[node]).length == 0)
) {
return [node];
}
const path = this.getPathForNode(tree[node], nodeID);
if (path !== null) {
return [node].concat(path);
}
}
return null;
}
// NOTE: this will be used in the future when we remove path and use tree instead
async _getAndExpandPathFromTree(tree: ElementSelectorNode) {
this.getAndExpandPath(this.getPathForNode(tree, null) ?? []);
}
onElementSelected = (option?: {
cancelSelector?: boolean;
expandPathToElement?: boolean;
}) =>
debounce(async (selectedKey: ElementID) => {
if (option?.cancelSelector) {
this.setState({elementSelector: null, axElementSelector: null});
}
if (option?.expandPathToElement) {
const data = this.props.ax
? this.state.axElementSelector
: this.state.elementSelector;
await this.getAndExpandPath(
this.getPathForNode(data?.tree ?? {}, selectedKey) ?? [],
);
}
this.onElementHovered(selectedKey);
this.props.onSelect(selectedKey);
});
onElementSelectedAtMainSection = this.onElementSelected({
cancelSelector: true,
});
onElementSelectedAndExpanded = this.onElementSelected({
expandPathToElement: true,
});
onElementHovered = debounce((key: ElementID | null | undefined) => {
if (!this.props.client.isConnected) {
return;
}
this.props.client.call(this.call().SET_HIGHLIGHTED, {
id: key,
isAlignmentMode: this.props.inAlignmentMode,
});
});
onElementExpanded = (
id: ElementID,
deep: boolean,
forceExpand: boolean = false,
) => {
const shouldExpand = forceExpand || !this.elements()[id].expanded;
if (shouldExpand) {
this.updateElement(id, {expanded: shouldExpand});
}
this.getChildren(id, {})
.then((children) => {
if (deep) {
children.forEach((child) =>
this.onElementExpanded(child.id, deep, shouldExpand),
);
}
})
.catch((e) => console.error('[layout] getChildren failed:', e));
if (!shouldExpand) {
this.updateElement(id, {expanded: shouldExpand});
}
};
render() {
const selectorData = this.props.ax
? this.state.axElementSelector
: this.state.elementSelector;
return this.root() ? (
<Layout.Top>
{selectorData && selectorData.leaves.length > 1 ? (
<MultipleSelectorSection
initialSelectedElement={this.selected()}
elements={selectorData.elements}
onElementSelected={this.onElementSelectedAndExpanded}
onElementHovered={this.onElementHovered}
/>
) : (
<div />
)}
<ElementsInspector
onElementSelected={this.onElementSelectedAtMainSection}
onElementHovered={this.onElementHovered}
onElementExpanded={this.onElementExpanded}
searchResults={this.props.searchResults}
selected={this.selected()}
root={this.root()}
elements={this.elements()}
focused={this.focused()}
contextMenuExtensions={this.getAXContextMenuExtensions}
/>
</Layout.Top>
) : null;
}
}