Ant tree -> React complex tree

Summary:
Upgraded from ant tree library to the much more capable React complex tree. Added the following:
1. Ability to expand / collapse nodes while automatically expanding / collapsing active/inactive children when they change
2. Keyboard controls of tree all the time
3. Basic search functionality
4. Selecting node in tree focuses and scrolls in the tree
5. Hover state for tree

Reviewed By: lblasa

Differential Revision: D40633876

fbshipit-source-id: 8dcef5ec2c277e476a3eb3cdaef62b15c25323c0
This commit is contained in:
Luke De Feo
2022-10-25 07:10:38 -07:00
committed by Facebook GitHub Bot
parent 0c52ad307e
commit f282a5eb8a
8 changed files with 191 additions and 124 deletions

View File

@@ -8,76 +8,108 @@
*/
import {Id, UINode} from '../types';
import {Tree as AntTree, TreeDataNode} from 'antd';
import {DownOutlined} from '@ant-design/icons';
import React from 'react';
import React, {useEffect, useRef} from 'react';
import {
Tree as ComplexTree,
ControlledTreeEnvironment,
TreeItem,
} from 'react-complex-tree';
import {plugin} from '../index';
import {usePlugin, useValue} from 'flipper-plugin';
import {
InteractionMode,
TreeEnvironmentRef,
} from 'react-complex-tree/lib/esm/types';
export function Tree(props: {
rootId: Id;
nodes: Map<Id, UINode>;
selectedNode?: Id;
hoveredNode?: Id;
onSelectNode: (id: Id) => void;
onHoveredNode: (id?: Id) => void;
}) {
const [antTree, inactive] = nodesToAntTree(props.rootId, props.nodes);
const instance = usePlugin(plugin);
const expandedItems = useValue(instance.treeState).expandedNodes;
const items = toComplexTree(props.nodes);
const treeRef = useRef<TreeEnvironmentRef>();
useEffect(() => {
//this makes the keyboard arrow controls work always, even when using the visualiser
treeRef.current?.focusTree('tree', true);
}, [props.hoveredNode, props.selectedNode]);
return (
<div
onMouseLeave={() => {
//This div exists so when mouse exits the entire tree then unhover
props.onHoveredNode(undefined);
}}>
<AntTree
showIcon
showLine
titleRender={(node) => {
return (
<div
onMouseEnter={() => {
props.onHoveredNode(node.key as Id);
}}>
{node.title}
</div>
<ControlledTreeEnvironment
ref={treeRef as any}
items={items}
getItemTitle={(item) => item.data.name}
canRename={false}
canDragAndDrop={false}
canSearch
autoFocus
viewState={{
tree: {
focusedItem: props.hoveredNode,
expandedItems,
selectedItems: props.selectedNode ? [props.selectedNode] : [],
},
}}
onFocusItem={(item) => props.onHoveredNode(item.index)}
onExpandItem={(item) => {
instance.treeState.update((draft) => {
draft.expandedNodes.push(item.index);
});
}}
onCollapseItem={(item) =>
instance.treeState.update((draft) => {
draft.expandedNodes = draft.expandedNodes.filter(
(expandedItemIndex) => expandedItemIndex !== item.index,
);
}}
selectedKeys={[props.selectedNode ?? '']}
onSelect={(selected) => {
props.onSelectNode(selected[0] as Id);
}}
defaultExpandAll
expandedKeys={[...props.nodes.keys()].filter(
(key) => !inactive.includes(key),
)}
switcherIcon={<DownOutlined />}
treeData={[antTree]}
})
}
onSelectItems={(items) => props.onSelectNode(items[0])}
defaultInteractionMode={{
mode: 'custom',
extends: InteractionMode.DoubleClickItemToExpand,
createInteractiveElementProps: (
item,
treeId,
actions,
renderFlags,
) => ({
onClick: () => {
if (renderFlags.isSelected) {
actions.unselectItem();
} else {
actions.selectItem();
}
},
onMouseOver: () => {
props.onHoveredNode(item.index);
},
}),
}}>
<ComplexTree
treeId="tree"
rootItem={props.rootId as any} //the typing in in the library is wrong here
treeLabel="UI"
/>
</div>
</ControlledTreeEnvironment>
);
}
function nodesToAntTree(
root: Id,
nodes: Map<Id, UINode>,
): [TreeDataNode, Id[]] {
const inactive: Id[] = [];
function uiNodeToAntNode(id: Id): TreeDataNode {
const node = nodes.get(id);
if (node?.activeChild) {
for (const child of node.children) {
if (child !== node?.activeChild) {
inactive.push(child);
}
}
}
return {
key: id,
title: node?.name,
children: node?.children.map((id) => uiNodeToAntNode(id)),
function toComplexTree(nodes: Map<Id, UINode>): Record<Id, TreeItem<UINode>> {
const res: Record<Id, TreeItem<UINode>> = {};
for (const node of nodes.values()) {
res[node.id] = {
index: node.id,
canMove: false,
canRename: false,
children: node.children,
data: node,
hasChildren: node.children.length > 0,
};
}
return [uiNodeToAntNode(root), inactive];
return res;
}

View File

@@ -10,11 +10,10 @@
import React from 'react';
import {Id, Snapshot, Tag, UINode} from '../types';
import {styled, Layout, theme} from 'flipper-plugin';
import {Typography} from 'antd';
export const Visualization2D: React.FC<
{
root: Id;
rootId: Id;
nodes: Map<Id, UINode>;
snapshots: Map<Id, Snapshot>;
hoveredNode?: Id;
@@ -24,7 +23,7 @@ export const Visualization2D: React.FC<
modifierPressed: boolean;
} & React.HTMLAttributes<HTMLDivElement>
> = ({
root,
rootId,
nodes,
snapshots,
hoveredNode,
@@ -34,16 +33,13 @@ export const Visualization2D: React.FC<
modifierPressed,
}) => {
//todo, do a bfs search for the first bounds found
const rootBounds = nodes.get(root)?.bounds;
const rootSnapshot = snapshots.get(root);
const rootBounds = nodes.get(rootId)?.bounds;
const rootSnapshot = snapshots.get(rootId);
if (!rootBounds) {
return null;
}
return (
<Layout.Container gap="large">
<Typography.Title>Visualizer</Typography.Title>
<div
onMouseLeave={(e) => {
e.stopPropagation();
@@ -70,7 +66,7 @@ export const Visualization2D: React.FC<
/>
) : null}
<Visualization2DNode
nodeId={root}
nodeId={rootId}
nodes={nodes}
snapshots={snapshots}
hoveredNode={hoveredNode}
@@ -80,7 +76,6 @@ export const Visualization2D: React.FC<
modifierPressed={modifierPressed}
/>
</div>
</Layout.Container>
);
};

View File

@@ -47,18 +47,19 @@ export function Component() {
if (rootId) {
return (
<>
<Layout.Horizontal grow>
<Layout.ScrollContainer>
<Layout.Horizontal>
<Tree
selectedNode={selectedNode}
hoveredNode={hoveredNode}
onSelectNode={setSelectedNode}
onHoveredNode={setHoveredNode}
nodes={nodes}
rootId={rootId}
/>
</Layout.ScrollContainer>
<Visualization2D
root={rootId}
rootId={rootId}
nodes={nodes}
snapshots={snapshots}
hoveredNode={hoveredNode}
@@ -67,12 +68,10 @@ export function Component() {
onSelectNode={setSelectedNode}
modifierPressed={ctrlPressed}
/>
</Layout.Horizontal>
</Layout.ScrollContainer>
{selectedNode && renderSidebar(nodes.get(selectedNode))}
</>
</Layout.Horizontal>
);
}
return <div>Nothing yet</div>;
return <div>Loading...</div>;
}

View File

@@ -8,7 +8,7 @@
*/
import {PluginClient, createState, createDataSource} from 'flipper-plugin';
import {Events, Id, PerfStatsEvent, Snapshot, UINode} from './types';
import {Events, Id, PerfStatsEvent, Snapshot, TreeState, UINode} from './types';
export function plugin(client: PluginClient<Events>) {
const rootId = createState<Id | undefined>(undefined);
@@ -25,6 +25,8 @@ export function plugin(client: PluginClient<Events>) {
const nodesAtom = createState<Map<Id, UINode>>(new Map());
const snapshotsAtom = createState<Map<Id, Snapshot>>(new Map());
const treeState = createState<TreeState>({expandedNodes: []});
client.onMessage('coordinateUpdate', (event) => {
nodesAtom.update((draft) => {
const node = draft.get(event.nodeId);
@@ -36,6 +38,8 @@ export function plugin(client: PluginClient<Events>) {
}
});
});
const seenNodes = new Set<Id>();
client.onMessage('subtreeUpdate', (event) => {
snapshotsAtom.update((draft) => {
draft.set(event.rootId, event.snapshot);
@@ -45,9 +49,35 @@ export function plugin(client: PluginClient<Events>) {
draft.set(node.id, node);
}
});
treeState.update((draft) => {
for (const node of event.nodes) {
if (!seenNodes.has(node.id)) {
draft.expandedNodes.push(node.id);
}
seenNodes.add(node.id);
if (node.activeChild) {
const inactiveChildren = node.children.filter(
(child) => child !== node.activeChild,
);
draft.expandedNodes = draft.expandedNodes.filter(
(nodeId) => !inactiveChildren.includes(nodeId),
);
draft.expandedNodes.push(node.activeChild);
}
}
});
});
return {rootId, snapshots: snapshotsAtom, nodes: nodesAtom, perfEvents};
return {
rootId,
snapshots: snapshotsAtom,
nodes: nodesAtom,
perfEvents,
treeState,
};
}
export {Component} from './components/main';

View File

@@ -14,6 +14,7 @@
],
"dependencies": {
"react-color": "^2.19.3",
"react-complex-tree" : "^1.1.11",
"react-hotkeys-hook": "^3.4.7"
},
"bugs": {

View File

@@ -7,6 +7,8 @@
* @format
*/
import {TreeItemIndex} from 'react-complex-tree';
export type Events = {
init: InitEvent;
subtreeUpdate: SubtreeUpdateEvent;
@@ -89,7 +91,9 @@ export type Color = {
};
export type Snapshot = string;
export type Id = number;
export type Id = number | TreeItemIndex;
export type TreeState = {expandedNodes: Id[]};
export type Tag = 'Native' | 'Declarative' | 'Android' | 'Litho ';

View File

@@ -1644,6 +1644,11 @@ react-color@^2.19.3:
reactcss "^1.2.0"
tinycolor2 "^1.4.1"
react-complex-tree@^1.1.11:
version "1.1.11"
resolved "https://registry.yarnpkg.com/react-complex-tree/-/react-complex-tree-1.1.11.tgz#430520d12908b033a4b278be0dfd8d0aa6654a85"
integrity sha512-hAkm2ZRH2lwZd7NEzZMQI8db/jI5T2fJsbwHX8oNPrG/WPdakc3eNpm2A4gLk2SBa88HeU6mnauVXg6Q6fJLow==
react-devtools-core@^4.26.1:
version "4.26.1"
resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.26.1.tgz#2893fea58089be64c5356d5bd0eebda8d1bbf317"

View File

@@ -7,6 +7,7 @@
*/
@import '../node_modules/antd/dist/antd.less';
@import './typography.less';
@import (inline) './plugins/public/node_modules/react-complex-tree/lib/style.css';
/* Based on: https://www.figma.com/file/4e6BMdm2SuZ1L7FSuOPQVC/Flipper?node-id=620%3A84636 */
@background-transparent-hover: rgba(0, 0, 0, 0.1);