Summary: Fixed a potential NPE that could happen in layout editor. Reviewed By: passy Differential Revision: D30870185 fbshipit-source-id: 3ae5860ce63761336d8bc508dd8eff761a5ab1f8
474 lines
13 KiB
TypeScript
474 lines
13 KiB
TypeScript
/**
|
|
* Copyright (c) Facebook, Inc. and its 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.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,
|
|
});
|
|
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;
|
|
}
|
|
}
|